Compare commits

...

17 Commits

Author SHA1 Message Date
MengMengCode c7ba19fa34 release: v1.0.9 2026-06-06 15:10:54 +08:00
MengMengCode ffedf801e7 添加了子用户列表功能 2026-06-06 15:10:24 +08:00
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
MengMengCode bb8a646de7 Make uninstall remove all CLICD data 2026-06-05 20:12:36 +08:00
MengMengCode a2da61e076 Install binary with atomic replacement 2026-06-05 20:03:16 +08:00
MengMengCode 49a6981e28 Import existing LXC containers into CLICD 2026-06-05 20:01:24 +08:00
MengMengCode 7613d9135b Sync CLI and Web container state 2026-06-05 19:58:42 +08:00
MengMengCode db347aeeb6 Add installer uninstall mode 2026-06-05 19:54:25 +08:00
MengMengCode 3c9ee3552e commit 2026-06-05 19:50:58 +08:00
MengMengCode 48dee79129 commit 2026-06-05 19:45:54 +08:00
49 changed files with 5075 additions and 622 deletions
+9
View File
@@ -35,6 +35,15 @@ jobs:
go-version: "1.22.x" go-version: "1.22.x"
cache-dependency-path: backend/go.sum 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 - name: Build
shell: bash shell: bash
run: bash build.sh run: bash build.sh
+2
View File
@@ -62,3 +62,5 @@ backend/tmp/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
linux.txt
push-release.ps1
+15 -80
View File
@@ -37,93 +37,28 @@ CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、C
## 安装 ## 安装
推荐使用 GitHub Actions 构建出的 Release 产物。下载 `clicd-linux-amd64.tar.gz` 后在目标服务器上执行 一键安装
```bash ```bash
tar -xzf clicd-linux-amd64.tar.gz curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
cd clicd-linux-amd64
sudo ./install.sh
``` ```
安装完成后访问 一键卸载
```text
http://YOUR_SERVER_IP:8999
```
首次启动时会自动初始化管理员账号:
```text
Username: admin
Password: 随机 16 位密码
```
安装脚本会尝试从 systemd 日志中输出初始账号密码。如果机器上已经存在 `/root/.clicd/config.json`,则不会重新生成密码。
查看初始密码日志:
```bash ```bash
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
``` ```
## GitHub Actions 构建 ![alt text](/img/image.png)
![alt text](/img/image-1.png)
![alt text](/img/image-2.png)
仓库内置 `.github/workflows/build.yml` ## Star History
- 推送到 `main``master` 时自动构建 Linux amd64 产物。 <a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
- 创建 `v*` 标签时自动发布 GitHub Release。 <picture>
- 支持手动 `workflow_dispatch` 构建。 <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>
```bash </a>
git tag v1.0.0
git push origin v1.0.0
```
Release 会包含:
```text
clicd-linux-amd64.tar.gz
clicd-linux-amd64
SHA256SUMS
```
## CLI 模式
进入 CLI
```bash
clicd cli
```
仅使用 CLI,不自动拉起 Web 服务:
```bash
systemctl stop clicd
systemctl disable clicd
clicd cli --no-web
```
重新启用 Web 控制台:
```bash
systemctl enable --now clicd
```
## 常用服务命令
```bash
systemctl status clicd
systemctl restart clicd
journalctl -u clicd -f
```
## 注意事项
- 需要 root 权限安装和运行。
- 宿主机需要支持 LXC。
- NAT 和端口映射依赖 iptables。
- 安全告警依赖 conntrack 或 `/proc/net/nf_conntrack`
- IPv6 分配要求宿主机拥有可用公网 IPv6 地址段。
- 配置文件位于 `/root/.clicd/config.json`,其中包含敏感信息,不要提交到公开仓库。
+36 -43
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"net" "net"
@@ -11,8 +13,6 @@ import (
"time" "time"
"clicd/internal/config" "clicd/internal/config"
"github.com/golang-jwt/jwt/v5"
) )
type ApiKey struct { type ApiKey struct {
@@ -116,6 +116,11 @@ func generateShortID() string {
// hashKey creates a simple hash for storage (not reversible) // hashKey creates a simple hash for storage (not reversible)
func hashKey(key string) string { func hashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func legacyHashKey(key string) string {
b := make([]byte, 32) b := make([]byte, 32)
for i := range key { for i := range key {
b[i%32] ^= key[i] 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 // validateApiKey checks if the given key is valid and IP is allowed
func validateApiKey(rawKey, clientIP string) bool { func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey) hashed := hashKey(rawKey)
legacyHashed := legacyHashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys { 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 == "" { if k.IPWhitelist == "" {
return true return true
} }
@@ -137,6 +144,29 @@ func validateApiKey(rawKey, clientIP string) bool {
return false 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 // isIPAllowed checks if clientIP matches any entry in the whitelist
func isIPAllowed(clientIP, whitelist string) bool { func isIPAllowed(clientIP, whitelist string) bool {
clientIP = strings.TrimSpace(clientIP) clientIP = 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 { func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Check header apiKey := apiKeyFromRequest(r)
apiKey := r.Header.Get("X-API-Key") if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
if apiKey == "" {
// Check query param
apiKey = r.URL.Query().Get("api_key")
}
if apiKey == "" {
// Check Bearer token (some clients use this)
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
apiKey = strings.TrimPrefix(auth, "Bearer ")
}
}
// Get client IP
clientIP := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
clientIP = strings.Split(forwarded, ",")[0]
}
if apiKey == "" || !validateApiKey(apiKey, clientIP) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
return 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) updateApiKeyLastUsed(apiKey)
next(w, r) next(w, r)
} }
+23 -5
View File
@@ -67,6 +67,27 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
return nil, false return nil, false
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, false
}
// For sub-user tokens, check token_version against stored version (password rotation invalidation)
if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat)
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser {
stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely.
if stored > 0 && (!hasVersion || tokenVersion != stored) {
return nil, false
}
break
}
}
}
return claims, ok return claims, ok
} }
@@ -100,10 +121,7 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
ip := r.RemoteAddr ip := clientIP(r)
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = forwarded
}
ua := r.Header.Get("User-Agent") ua := r.Header.Get("User-Agent")
if req.Username != config.AppConfig.AdminUser { if req.Username != config.AppConfig.AdminUser {
@@ -192,7 +210,7 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
tokenString := tokenFromRequest(r) tokenString := tokenFromRequest(r)
if !isValidToken(tokenString) { if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
return return
} }
+48 -4
View File
@@ -9,6 +9,7 @@ import (
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "clicd/internal/lxc"
"clicd/internal/version"
) )
var lxcManager = lxc.NewManager() var lxcManager = lxc.NewManager()
@@ -30,16 +31,35 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/containers/") path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
parts := strings.SplitN(path, "/", 2) parts := strings.SplitN(path, "/", 2)
c := containerByIdentifier(parts[0]) c := containerByIdentifier(parts[0])
if c == nil { id := 0
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) if c != nil {
return id = c.ID
} }
id := c.ID
action := "" action := ""
if len(parts) > 1 { if len(parts) > 1 {
action = parts[1] action = parts[1]
} }
// Snapshot delete/restore operations: allow even if the container was deleted
isSnapshotDelete := strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete
isSnapshotRestore := strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost
isSnapshotAction := isSnapshotDelete || isSnapshotRestore
if !isSnapshotAction && c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
if isSnapshotAction && id == 0 {
// For orphaned snapshots, resolve containerID from the snapshot itself
snapshotID := strings.TrimPrefix(action, "snapshots/")
snapshotID = strings.TrimSuffix(snapshotID, "/restore")
snapshot := config.FindSnapshot(snapshotID)
if snapshot == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
return
}
id = snapshot.ContainerID
}
switch { switch {
case action == "start" && r.Method == http.MethodPost: case action == "start" && r.Method == http.MethodPost:
HandleSingleTaskAction(w, r, id, "start") HandleSingleTaskAction(w, r, id, "start")
@@ -69,6 +89,8 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
updateExpiry(w, r, id) updateExpiry(w, r, id)
case action == "ipv6" && r.Method == http.MethodPost: case action == "ipv6" && r.Method == http.MethodPost:
assignIPv6(w, r, id) assignIPv6(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost: case action == "port-mappings" && r.Method == http.MethodPost:
addPortMapping(w, r, id) addPortMapping(w, r, id)
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut: case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
@@ -105,6 +127,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
return return
} }
if !isTemplateEnabledAndDownloaded(cfg.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
if cfg.VCPU <= 0 { if cfg.VCPU <= 0 {
cfg.VCPU = 1 cfg.VCPU = 1
} }
@@ -121,6 +147,9 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
return return
} }
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil { if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
@@ -311,6 +340,10 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return return
} }
if isSubUserRequest(r) {
HandleEnabledImages(w, r)
return
}
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates})
} }
@@ -438,3 +471,14 @@ func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings}) 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}) 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) { func ensureImageEnabled(id string) {
// If the enabled list is empty, all templates are currently enabled by default. // 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. // 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"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return return
} }
if cfg.SubUserSnapshotLimit <= 0 {
cfg.SubUserSnapshotLimit = 3
}
// Apply KSM // Apply KSM
if cfg.KSMEnabled { 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 ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -73,7 +74,7 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
// HandleWebSSH proxies an SSH session to the browser over WebSocket. // HandleWebSSH proxies an SSH session to the browser over WebSocket.
func HandleWebSSH(w http.ResponseWriter, r *http.Request) { func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket") ticket := webSSHTicketFromRequest(r)
if ticket == "" { if ticket == "" {
http.Error(w, "ticket required", http.StatusUnauthorized) http.Error(w, "ticket required", http.StatusUnauthorized)
return return
@@ -114,7 +115,11 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
http.Error(w, "container ip is not available", http.StatusBadRequest) http.Error(w, "container ip is not available", http.StatusBadRequest)
return 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 { if err != nil {
log.Printf("WebSSH upgrade failed: %v", err) log.Printf("WebSSH upgrade failed: %v", err)
return return
@@ -141,7 +146,7 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
Auth: []ssh.AuthMethod{ Auth: []ssh.AuthMethod{
ssh.Password(c.SSHPassword), ssh.Password(c.SSHPassword),
}, },
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: containerHostKeyCallback(c),
Timeout: 4 * time.Second, Timeout: 4 * time.Second,
} }
@@ -250,6 +255,41 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
log.Printf("WebSSH disconnected for container %s", containerName) 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{}) { func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) {
defer func() { done <- struct{}{} }() defer func() { done <- struct{}{} }()
+265 -42
View File
@@ -21,6 +21,28 @@ func generateRandomStr(length int) string {
return hex.EncodeToString(b)[:length] 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 // HandleSubUserCreate creates a sub-user for a specific container
func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) { func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@@ -44,32 +66,36 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
} }
containerName := c.Name containerName := c.Name
// Check if sub-user already exists for this container // Check if sub-user already exists and return the same management password.
for i := range config.AppConfig.SubUsers { for i := range config.AppConfig.SubUsers {
su := &config.AppConfig.SubUsers[i] su := &config.AppConfig.SubUsers[i]
for _, cn := range su.ContainerNames { for _, uuid := range su.ContainerUUIDs {
if cn == containerName { if uuid == c.UUID {
if su.AccessCode == "" { if su.AccessCode == "" {
su.AccessCode = generateRandomStr(8) su.AccessCode = generateRandomStr(8)
} }
if su.PassHash == "" && su.Password != "" { password := su.Password
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil { message := "Sub-user link returned"
su.PassHash = string(hash) if password == "" {
password = generateRandomStr(16)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
return
} }
su.PassHash = string(hash)
su.Password = password
su.Token = ""
su.TokenVersion++
message = "Sub-user password generated"
} }
if su.Password == "" { su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.Password = generateRandomStr(16) su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
su.PassHash = string(hash)
}
}
su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
config.SaveConfig() config.SaveConfig()
// Return existing
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
Message: "Sub-user already exists", Message: message,
Data: *su, Data: newSubUserResponse(*su, password),
}) })
return return
} }
@@ -84,16 +110,13 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
// Generate short access code (8 chars, for URL sharing) // Generate short access code (8 chars, for URL sharing)
accessCode := generateRandomStr(8) accessCode := generateRandomStr(8)
// Generate JWT for sub-user
tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
subUser := config.SubUser{ subUser := config.SubUser{
ID: "sub-" + generateRandomStr(8), ID: "sub-" + generateRandomStr(8),
Username: username, Username: username,
Password: password, Password: password,
PassHash: string(hash), PassHash: string(hash),
ContainerNames: []string{containerName}, ContainerNames: []string{containerName},
Token: tokenStr, ContainerUUIDs: []string{c.UUID},
AccessCode: accessCode, AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"), CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
} }
@@ -102,7 +125,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
config.SaveConfig() config.SaveConfig()
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin") 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 // HandleSubUserLogin handles sub-user login
@@ -121,17 +144,24 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
clientUA := r.Header.Get("User-Agent")
// Find sub-user // Find sub-user
for _, su := range config.AppConfig.SubUsers { for _, su := range config.AppConfig.SubUsers {
if su.Username == req.Username { if su.Username == req.Username {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil { if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
// Generate fresh token containerUUIDs := activeSubUserContainerUUIDs(&su)
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
if len(containerUUIDs) == 0 { if len(containerUUIDs) == 0 {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
return return
} }
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour)) tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
config.AddLoginLog(su.Username, clientIP, clientUA, true)
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
@@ -142,6 +172,8 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
}, },
}) })
return return
} else {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
} }
} }
} }
@@ -166,19 +198,28 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
} }
// Find sub-user by access code // Find sub-user by access code
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
clientUA := r.Header.Get("User-Agent")
for _, su := range config.AppConfig.SubUsers { for _, su := range config.AppConfig.SubUsers {
if su.AccessCode == req.Code { if su.AccessCode == req.Code {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
return return
} }
containerUUIDs := subUserContainerUUIDs(su.ContainerNames) containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 { if len(containerUUIDs) == 0 {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
return return
} }
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour)) tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
config.AddLoginLog(su.Username, clientIP, clientUA, true)
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
@@ -195,10 +236,11 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
} }
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string { func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time, tokenVersion int) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub_user": username, "sub_user": username,
"container_uuids": containerUUIDs, "container_uuids": containerUUIDs,
"token_version": tokenVersion,
"exp": expiresAt.Unix(), "exp": expiresAt.Unix(),
"iat": time.Now().Unix(), "iat": time.Now().Unix(),
}) })
@@ -224,18 +266,6 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
names: make(map[string]bool), names: make(map[string]bool),
uuids: 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 { if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok {
for _, item := range containerUUIDs { for _, item := range containerUUIDs {
if uuid, ok := item.(string); ok { if uuid, ok := item.(string); ok {
@@ -359,15 +389,27 @@ func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
} }
filtered := make([]*Task, 0, len(tasks)) filtered := make([]*Task, 0, len(tasks))
for _, task := range 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) filtered = append(filtered, task)
continue
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
continue
}
}
if task.Config.Name != "" {
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
filtered = append(filtered, task)
}
} }
} }
return filtered return filtered
} }
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool { 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 { func isSubUserContainerActionAllowed(action string, method string) bool {
@@ -377,6 +419,12 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
switch { switch {
case action == "usage" || action == "traffic" || action == "random-port": case action == "usage" || action == "traffic" || action == "random-port":
return method == http.MethodGet 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": case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
return method == http.MethodPost return method == http.MethodPost
case strings.HasPrefix(action, "port-mappings/"): case strings.HasPrefix(action, "port-mappings/"):
@@ -386,16 +434,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 { func subUserContainerUUIDs(containerNames []string) []string {
uuids := make([]string, 0, len(containerNames)) uuids := make([]string, 0, len(containerNames))
for _, name := range containerNames { for _, name := range containerNames {
if c := config.FindContainerByName(name); c != nil && c.UUID != "" { if c := config.FindContainerByName(name); c != nil && c.UUID != "" {
uuids = append(uuids, c.UUID) uuids = appendUniqueString(uuids, c.UUID)
} }
} }
return uuids 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 { func splitPath(path string) []string {
parts := make([]string, 0) parts := make([]string, 0)
for _, p := range splitBy(path, "/") { for _, p := range splitBy(path, "/") {
@@ -420,3 +490,156 @@ func splitBy(s, sep string) []string {
result = append(result, current) result = append(result, current)
return result return result
} }
// SubUserListItem is the enriched sub-user info returned by the list API
type SubUserListItem struct {
ID string `json:"id"`
Username string `json:"username"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"`
ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"`
Password string `json:"password,omitempty"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login"`
LastLoginIP string `json:"last_login_ip"`
LastLoginUA string `json:"last_login_ua"`
}
// HandleSubUserList returns the list of all sub-users with container info
func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers {
item := SubUserListItem{
ID: su.ID,
Username: su.Username,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
Password: su.Password,
CreatedAt: su.CreatedAt,
}
// Resolve container name from first active UUID
for _, uuid := range su.ContainerUUIDs {
if c := config.FindContainerByUUID(uuid); c != nil {
item.ContainerName = c.Name
item.ContainerUUID = c.UUID
break
}
}
if item.ContainerName == "" && len(su.ContainerNames) > 0 {
item.ContainerName = su.ContainerNames[0]
}
// Find last login time
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
log := config.AppConfig.LoginLogs[i]
if log.Username == su.Username {
item.LastLogin = log.Time
item.LastLoginIP = log.IP
item.LastLoginUA = log.UserAgent
break
}
}
// Skip orphaned sub-users with no active containers
if item.ContainerName == "" && item.ContainerUUID == "" {
continue
}
result = append(result, item)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
}
// HandleSubUserAction handles actions on a specific sub-user
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
parts := strings.SplitN(path, "/", 2)
subUserID := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
// Find sub-user
var target *config.SubUser
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].ID == subUserID {
target = &config.AppConfig.SubUsers[i]
break
}
}
if target == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Sub-user not found"})
return
}
switch {
case action == "rotate-password" && r.Method == http.MethodPost:
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
target.PassHash = string(hash)
target.Password = password
target.Token = ""
target.TokenVersion++ // invalidate all existing tokens
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"password": password,
"access_code": target.AccessCode,
"username": target.Username,
}})
return
}
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
case action == "audit-logs" && r.Method == http.MethodGet:
// Filter audit logs for this sub-user
logs := filterSubUserAuditLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "login-logs" && r.Method == http.MethodGet:
// Filter login logs for this sub-user
logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
}
}
func filterSubUserAuditLogs(username string) []config.AuditLog {
result := make([]config.AuditLog, 0)
for i := len(config.AppConfig.AuditLogs) - 1; i >= 0; i-- {
log := config.AppConfig.AuditLogs[i]
if log.User == username || strings.HasPrefix(log.User, "user:") && strings.Contains(log.User, username) {
result = append(result, log)
}
}
if result == nil {
result = []config.AuditLog{}
}
return result
}
func filterSubUserLoginLogs(username string) []config.SavedLoginLog {
result := make([]config.SavedLoginLog, 0)
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
log := config.AppConfig.LoginLogs[i]
if log.Username == username {
result = append(result, log)
}
}
if result == nil {
result = []config.SavedLoginLog{}
}
return result
}
+32 -4
View File
@@ -35,6 +35,8 @@ type Task struct {
Config lxc.ContainerConfig `json:"config,omitempty"` Config lxc.ContainerConfig `json:"config,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
User string `json:"user,omitempty"` // who created this task User string `json:"user,omitempty"` // who created this task
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
} }
type TaskQueue struct { type TaskQueue struct {
@@ -100,6 +102,10 @@ func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string
} }
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string { func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
return q.EnqueueBatchWithAudit(taskType, ids, templateID, user, "", "")
}
func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, templateID string, user string, ip string, userAgent string) []string {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
var result []string var result []string
@@ -109,7 +115,7 @@ func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateI
if c != nil { if c != nil {
name = c.Name name = c.Name
} }
result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user)) result = append(result, q.enqueueSingleWithAudit(id, name, taskType, templateID, user, ip, userAgent))
} }
q.persistTasks() q.persistTasks()
return result return result
@@ -168,6 +174,10 @@ func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskTyp
} }
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string { func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
return q.enqueueSingleWithAudit(containerID, containerName, taskType, templateID, user, "", "")
}
func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string, taskType TaskType, templateID string, user string, ip string, userAgent string) string {
id := q.nextID id := q.nextID
q.nextID++ q.nextID++
task := &Task{ task := &Task{
@@ -179,6 +189,8 @@ func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"), CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID, TemplateID: templateID,
User: user, User: user,
IP: ip,
UserAgent: userAgent,
} }
q.enqueueTask(task) q.enqueueTask(task)
return task.ID return task.ID
@@ -318,10 +330,10 @@ func (q *TaskQueue) opWorker() {
if err != nil { if err != nil {
task.Status = "failed" task.Status = "failed"
task.Error = err.Error() task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser) config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else { } else {
task.Status = "done" task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser) config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
switch task.Type { switch task.Type {
case TaskStart: case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running") config.UpdateContainerStatus(task.ContainerID, "running")
@@ -418,6 +430,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
user = "user:" + subUser user = "user:" + subUser
} }
} }
ip := clientIP(r)
userAgent := r.Header.Get("User-Agent")
var taskType TaskType var taskType TaskType
var templateID string var templateID string
@@ -442,13 +456,17 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
templateID = c.Template templateID = c.Template
} }
} }
if !isTemplateEnabledAndDownloaded(templateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
taskType = TaskReinstall taskType = TaskReinstall
default: default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return return
} }
ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user) ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
jsonResponse(w, http.StatusAccepted, APIResponse{ jsonResponse(w, http.StatusAccepted, APIResponse{
Success: true, Success: true,
Message: "Task queued", Message: "Task queued",
@@ -504,6 +522,16 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].DiskGB < 1 { if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5 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 { 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()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return return
+661 -95
View File
@@ -2,14 +2,20 @@ package cli
import ( import (
"bufio" "bufio"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "clicd/internal/lxc"
"clicd/internal/version"
) )
var manager = lxc.NewManager() var manager = lxc.NewManager()
@@ -19,9 +25,13 @@ func Run() {
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
for { for {
if _, err := config.InitConfig(); err != nil {
fmt.Printf("重新加载配置失败: %v\n", err)
waitEnter(reader)
}
clearScreen() clearScreen()
printMenu() printMenu()
fmt.Print("\nSelect action [1-9,0/q]: ") fmt.Print("\n请选择操作 [1-12,0/q]: ")
input, _ := reader.ReadString('\n') input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input) input = strings.TrimSpace(input)
@@ -62,63 +72,79 @@ func Run() {
clearScreen() clearScreen()
cliToggleWebPanel() cliToggleWebPanel()
waitEnter(reader) waitEnter(reader)
case "10":
clearScreen()
cliImportExistingContainers()
waitEnter(reader)
case "11":
clearScreen()
cliUpgradeSystem(reader)
waitEnter(reader)
case "12":
clearScreen()
cliUninstall(reader)
return
case "0": case "0":
clearScreen() clearScreen()
cliShowInfo() cliShowInfo()
waitEnter(reader) waitEnter(reader)
case "q", "exit", "quit": case "q", "exit", "quit":
fmt.Println("Bye") fmt.Println("再见")
return return
default: default:
fmt.Println("Invalid selection") fmt.Println("无效选择")
} }
} }
} }
func printMenu() { func printMenu() {
webStatus := "start" webStatus := "启动"
if isWebPanelRunning() { if isWebPanelRunning() {
webStatus = "stop" webStatus = "停止"
} }
fmt.Println() fmt.Println()
fmt.Println(" ==========================================") fmt.Println(" ==========================================")
fmt.Println(" CLICD - LXC Container Manager") fmt.Println(" CLICD - LXC 容器管理器")
fmt.Println(" ==========================================") fmt.Println(" ==========================================")
fmt.Println() fmt.Println()
fmt.Printf(" Web panel: %s (port %d)\n", func() string { fmt.Printf(" Web 面板: %s (端口 %d)\n", func() string {
if isWebPanelRunning() { if isWebPanelRunning() {
return "running" return "运行中"
} }
return "stopped" return "已停止"
}(), config.AppConfig.Port) }(), config.AppConfig.Port)
fmt.Printf(" 当前版本: %s\n", version.Current())
fmt.Println() fmt.Println()
fmt.Println(" 1. List containers") fmt.Println(" 1. 查看容器列表")
fmt.Println(" 2. Create container") fmt.Println(" 2. 创建容器")
fmt.Println(" 3. Start container") fmt.Println(" 3. 开机容器")
fmt.Println(" 4. Stop container") fmt.Println(" 4. 关机容器")
fmt.Println(" 5. Restart container") fmt.Println(" 5. 重启容器")
fmt.Println(" 6. Delete container") fmt.Println(" 6. 删除容器")
fmt.Println(" 7. Reinstall container") fmt.Println(" 7. 重装容器系统")
fmt.Println(" 8. Reset web admin password") fmt.Println(" 8. 重置 Web 管理员密码")
fmt.Printf(" 9. %s web panel\n", webStatus) fmt.Printf(" 9. %s Web 面板\n", webStatus)
fmt.Println(" 0. System info") fmt.Println(" 10. 导入现有 LXC 容器")
fmt.Println(" q. Quit") fmt.Println(" 11. 检查并升级 CLICD")
fmt.Println(" 12. 卸载 CLICD")
fmt.Println(" 0. 系统信息")
fmt.Println(" q. 退出")
} }
func cliListContainers() { func cliListContainers() {
containers, err := manager.ListContainers() containers, err := manager.ListContainers()
if err != nil { if err != nil {
fmt.Printf("Failed to list containers: %v\n", err) fmt.Printf("获取容器列表失败: %v\n", err)
return return
} }
if len(containers) == 0 { if len(containers) == 0 {
fmt.Println("\nNo containers") fmt.Println("\n暂无容器")
return return
} }
fmt.Println() 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)) fmt.Println(strings.Repeat("-", 94))
for _, c := range containers { for _, c := range containers {
ssh := "-" ssh := "-"
@@ -131,23 +157,23 @@ func cliListContainers() {
} }
func cliCreateContainer(reader *bufio.Reader) { func cliCreateContainer(reader *bufio.Reader) {
fmt.Println("\n--- Create container ---") fmt.Println("\n--- 创建容器 ---")
name := promptString(reader, "Container name", "") name := promptString(reader, "容器名称", "")
if name == "" { if name == "" {
fmt.Println("Container name is required") fmt.Println("容器名称不能为空")
return return
} }
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
fmt.Println("\nAvailable templates:") fmt.Println("\n可用镜像:")
for i, template := range templates { for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name) 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) { if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("Invalid template selection") fmt.Println("镜像选择无效")
return return
} }
@@ -155,162 +181,701 @@ func cliCreateContainer(reader *bufio.Reader) {
Name: name, Name: name,
TemplateID: templates[tmplIdx-1].ID, TemplateID: templates[tmplIdx-1].ID,
VCPU: promptFloat(reader, "vCPU", 1), VCPU: promptFloat(reader, "vCPU", 1),
RAMMB: promptInt(reader, "Memory (MB)", 512), RAMMB: promptInt(reader, "内存 (MB)", 512),
DiskGB: promptInt(reader, "Disk (GB)", 10), DiskGB: promptInt(reader, "磁盘 (GB)", 10),
NetworkBWMbps: promptInt(reader, "Network bandwidth (Mbps)", 100), NetworkBWMbps: promptInt(reader, "网络带宽 (Mbps)", 100),
MonthlyTrafficGB: promptInt(reader, "Monthly traffic (GB)", 1000), MonthlyTrafficGB: promptInt(reader, "月流量 (GB)", 1000),
IOSpeedMBps: promptInt(reader, "IO speed (MB/s)", 500), IOSpeedMBps: promptInt(reader, "IO 速度 (MB/s)", 500),
ExtraPorts: promptPortList(reader, "Extra NAT ports, comma separated"), ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
} }
fmt.Printf("\nCreating container %s ...\n", name) fmt.Printf("\n正在创建容器 %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil { if err := manager.CreateContainer(cfg); err != nil {
fmt.Printf("Create failed: %v\n", err) fmt.Printf("创建失败: %v\n", err)
return return
} }
container := config.FindContainerByName(name) container := config.FindContainerByName(name)
fmt.Printf("Container %s created successfully\n", name) fmt.Printf("容器 %s 创建成功\n", name)
if container != nil { if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort) fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
} }
restartWebPanelForConfigChange()
} }
func cliStartContainer(reader *bufio.Reader) { func cliStartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "start") id, name := selectContainer(reader, "开机")
if id == 0 { if id == 0 {
return return
} }
if err := manager.StartContainer(id); err != nil { if err := manager.StartContainer(id); err != nil {
fmt.Printf("Start failed: %v\n", err) fmt.Printf("开机失败: %v\n", err)
return return
} }
fmt.Printf("Container %s started\n", name) fmt.Printf("容器 %s 已开机\n", name)
} }
func cliStopContainer(reader *bufio.Reader) { func cliStopContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "stop") id, name := selectContainer(reader, "关机")
if id == 0 { if id == 0 {
return return
} }
if err := manager.StopContainer(id); err != nil { if err := manager.StopContainer(id); err != nil {
fmt.Printf("Stop failed: %v\n", err) fmt.Printf("关机失败: %v\n", err)
return return
} }
fmt.Printf("Container %s stopped\n", name) fmt.Printf("容器 %s 已关机\n", name)
} }
func cliRestartContainer(reader *bufio.Reader) { func cliRestartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "restart") id, name := selectContainer(reader, "重启")
if id == 0 { if id == 0 {
return return
} }
if err := manager.RestartContainer(id); err != nil { if err := manager.RestartContainer(id); err != nil {
fmt.Printf("Restart failed: %v\n", err) fmt.Printf("重启失败: %v\n", err)
return return
} }
fmt.Printf("Container %s restarted\n", name) fmt.Printf("容器 %s 已重启\n", name)
} }
func cliDeleteContainer(reader *bufio.Reader) { func cliDeleteContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "delete") id, name := selectContainer(reader, "删除")
if id == 0 { if id == 0 {
return 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" { if strings.ToLower(confirm) != "yes" {
fmt.Println("Canceled") fmt.Println("已取消")
return return
} }
if err := manager.DestroyContainer(id); err != nil { if err := manager.DestroyContainer(id); err != nil {
fmt.Printf("Delete failed: %v\n", err) fmt.Printf("删除失败: %v\n", err)
return return
} }
fmt.Printf("Container %s deleted\n", name) fmt.Printf("容器 %s 已删除\n", name)
restartWebPanelForConfigChange()
} }
func cliReinstallContainer(reader *bufio.Reader) { func cliReinstallContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "reinstall") id, name := selectContainer(reader, "重装")
if id == 0 { if id == 0 {
return return
} }
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
fmt.Println("\nAvailable templates:") fmt.Println("\n可用镜像:")
for i, template := range templates { for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name) 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) { if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("Invalid template selection") fmt.Println("镜像选择无效")
return 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" { if strings.ToLower(confirm) != "yes" {
fmt.Println("Canceled") fmt.Println("已取消")
return return
} }
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil { if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
fmt.Printf("Reinstall failed: %v\n", err) fmt.Printf("重装失败: %v\n", err)
return return
} }
fmt.Printf("Container %s reinstalled\n", name) fmt.Printf("容器 %s 已重装\n", name)
restartWebPanelForConfigChange()
} }
func cliResetPassword(reader *bufio.Reader) { func cliResetPassword(reader *bufio.Reader) {
newPass := promptString(reader, "New admin password (at least 6 chars)", "") newPass := promptString(reader, "新的管理员密码(至少 6 位)", "")
if len(newPass) < 6 { if len(newPass) < 6 {
fmt.Println("Password must be at least 6 chars") fmt.Println("密码至少需要 6 位")
return return
} }
confirm := promptString(reader, "Confirm password", "") confirm := promptString(reader, "确认密码", "")
if newPass != confirm { if newPass != confirm {
fmt.Println("Passwords do not match") fmt.Println("两次输入的密码不一致")
return return
} }
if err := config.ResetAdminPassword(newPass); err != nil { if err := config.ResetAdminPassword(newPass); err != nil {
fmt.Printf("Reset failed: %v\n", err) fmt.Printf("重置失败: %v\n", err)
return return
} }
fmt.Println("Admin password reset. Restart the web service for it to take effect.") fmt.Println("管理员密码已重置。")
restartWebPanelForConfigChange()
} }
func cliToggleWebPanel() { func cliToggleWebPanel() {
if isWebPanelRunning() { if isWebPanelRunning() {
cmd := exec.Command("systemctl", "stop", "clicd") if err := stopService("clicd"); err != nil {
if err := cmd.Run(); err != nil { fmt.Printf("停止 Web 面板失败: %v\n", err)
fmt.Printf("Failed to stop web panel: %v\n", err)
return return
} }
fmt.Println("Web panel stopped. LXC containers are not affected.") fmt.Println("Web 面板已停止,LXC 容器不会受影响。")
return return
} }
cmd := exec.Command("systemctl", "start", "clicd") if err := startService("clicd"); err != nil {
if err := cmd.Run(); err != nil { fmt.Printf("启动 Web 面板失败: %v\n", err)
fmt.Printf("Failed to start web panel: %v\n", err)
return 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 { func isWebPanelRunning() bool {
cmd := exec.Command("systemctl", "is-active", "clicd") if commandExists("systemctl") {
output, err := cmd.Output() cmd := exec.Command("systemctl", "is-active", "clicd")
if err != nil { output, err := cmd.Output()
return false if err == nil && strings.TrimSpace(string(output)) == "active" {
return true
}
} }
return strings.TrimSpace(string(output)) == "active" if commandExists("rc-service") {
cmd := exec.Command("rc-service", "clicd", "status")
return cmd.Run() == nil
}
return false
}
func cliImportExistingContainers() {
fmt.Println("\n--- 导入现有 LXC 容器 ---")
fmt.Println("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
fmt.Println("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
imported, err := manager.ImportExistingClicdContainers()
if err != nil {
fmt.Printf("导入失败: %v\n", err)
return
}
if len(imported) == 0 {
fmt.Println("没有发现新的 ct-* 容器。")
return
}
fmt.Printf("已导入 %d 个容器:\n", len(imported))
for _, c := range imported {
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
}
restartWebPanelForConfigChange()
}
func cliUninstall(reader *bufio.Reader) {
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("卸载需要 root 权限。")
fmt.Println("请运行: sudo clicd cli --no-web")
return
}
confirm := promptString(reader, "输入 uninstall 继续卸载", "no")
if strings.ToLower(confirm) != "uninstall" {
fmt.Println("已取消")
return
}
destroyAllLXCContainers()
stopAndRemoveService()
removePath("/usr/local/bin/clicd")
removePath("/etc/sysctl.d/99-clicd.conf")
removePath("/var/log/clicd.log")
removePath("/var/log/clicd.err")
removePath("/root/.clicd")
removePath("/var/lib/lxc")
removePath("/var/cache/lxc")
reloadSysctl()
fmt.Println()
fmt.Println("CLICD 已卸载。")
fmt.Println("服务、二进制、配置、容器和 LXC 镜像缓存均已删除。")
}
func destroyAllLXCContainers() {
entries, err := os.ReadDir("/var/lib/lxc")
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
fmt.Printf("Destroying LXC container %s...\n", name)
runQuiet("lxc-stop", "-n", name, "-k")
runQuiet("lxc-destroy", "-n", name, "-f")
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])
}
}
}
}
func stopAndRemoveService() {
if commandExists("systemctl") {
runQuiet("systemctl", "stop", "clicd")
runQuiet("systemctl", "disable", "clicd")
removePath("/etc/systemd/system/clicd.service")
runQuiet("systemctl", "daemon-reload")
runQuiet("systemctl", "reset-failed", "clicd")
}
if commandExists("rc-service") {
runQuiet("rc-service", "clicd", "stop")
}
if commandExists("rc-update") {
runQuiet("rc-update", "del", "clicd", "default")
}
removePath("/etc/init.d/clicd")
}
func removePath(path string) {
if _, err := os.Lstat(path); os.IsNotExist(err) {
return
}
if err := os.RemoveAll(path); err != nil {
fmt.Printf("Failed to remove %s: %v\n", path, err)
return
}
fmt.Printf("Removed %s\n", path)
}
func reloadSysctl() {
if commandExists("sysctl") {
runQuiet("sysctl", "--system")
}
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run()
}
func restartWebPanelForConfigChange() {
if err := restartService("clicd"); err != nil {
fmt.Printf("Web 面板重载跳过: %v\n", err)
return
}
fmt.Println("Web 面板已重载并应用配置变更。")
}
func stopService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "stop", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "stop").Run()
}
return fmt.Errorf("no supported service manager found")
}
func startService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "start", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "start").Run()
}
return fmt.Errorf("no supported service manager found")
}
func restartService(name string) error {
if commandExists("systemctl") {
return exec.Command("systemctl", "restart", name).Run()
}
if commandExists("rc-service") {
return exec.Command("rc-service", name, "restart").Run()
}
return fmt.Errorf("no supported service manager found")
} }
func cliShowInfo() { func cliShowInfo() {
containers, err := manager.ListContainers() containers, err := manager.ListContainers()
if err != nil { if err != nil {
fmt.Printf("Failed to read container status: %v\n", err) fmt.Printf("读取容器状态失败: %v\n", err)
} }
total := len(containers) total := len(containers)
@@ -321,43 +886,44 @@ func cliShowInfo() {
} }
} }
fmt.Println("\n--- System info ---") fmt.Println("\n--- 系统信息 ---")
fmt.Printf("Web port: %d\n", config.AppConfig.Port) fmt.Printf("CLICD 版本: %s\n", version.Current())
fmt.Printf("Admin user: %s\n", config.AppConfig.AdminUser) fmt.Printf("Web 端口: %d\n", config.AppConfig.Port)
fmt.Printf("Containers: %d\n", total) fmt.Printf("管理员用户: %s\n", config.AppConfig.AdminUser)
fmt.Printf("Running: %d\n", running) fmt.Printf("容器总数: %d\n", total)
fmt.Printf("Stopped: %d\n", total-running) fmt.Printf("运行中: %d\n", running)
fmt.Printf("已停止: %d\n", total-running)
if hostname, err := os.Hostname(); err == nil { if hostname, err := os.Hostname(); err == nil {
fmt.Printf("Hostname: %s\n", hostname) fmt.Printf("主机名: %s\n", hostname)
} }
cmd := exec.Command("lxc-info", "--version") cmd := exec.Command("lxc-info", "--version")
output, err := cmd.Output() output, err := cmd.Output()
if err == nil { 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) { func selectContainer(reader *bufio.Reader, action string) (int, string) {
containers, err := manager.ListContainers() containers, err := manager.ListContainers()
if err != nil { if err != nil {
fmt.Printf("Failed to list containers: %v\n", err) fmt.Printf("获取容器列表失败: %v\n", err)
return 0, "" return 0, ""
} }
if len(containers) == 0 { if len(containers) == 0 {
fmt.Println("No containers available") fmt.Println("暂无可用容器")
return 0, "" return 0, ""
} }
fmt.Printf("\n--- Select container to %s ---\n", action) fmt.Printf("\n--- 选择要%s的容器 ---\n", action)
for i, container := range containers { for i, container := range containers {
fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status) 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) { if idx < 1 || idx > len(containers) {
fmt.Println("Invalid selection") fmt.Println("选择无效")
return 0, "" return 0, ""
} }
@@ -403,7 +969,7 @@ func clearScreen() {
} }
func waitEnter(reader *bufio.Reader) { func waitEnter(reader *bufio.Reader) {
fmt.Print("\nPress Enter to return to menu...") fmt.Print("\n Enter 返回菜单...")
reader.ReadString('\n') reader.ReadString('\n')
} }
@@ -417,7 +983,7 @@ func promptPortList(reader *bufio.Reader, label string) []int {
for _, part := range strings.Split(input, ",") { for _, part := range strings.Split(input, ",") {
value, err := strconv.Atoi(strings.TrimSpace(part)) value, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || value <= 0 || value > 65535 { 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 continue
} }
ports = append(ports, value) ports = append(ports, value)
+270 -49
View File
@@ -47,56 +47,73 @@ type SavedLoginLog struct {
// AuditLog represents an operation log entry // AuditLog represents an operation log entry
type AuditLog struct { type AuditLog struct {
Time string `json:"time"` Time string `json:"time"`
Action string `json:"action"` Action string `json:"action"`
Target string `json:"target"` Target string `json:"target"`
Detail string `json:"detail"` Detail string `json:"detail"`
User string `json:"user"` User string `json:"user"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Success *bool `json:"success,omitempty"`
Error string `json:"error,omitempty"`
} }
// OversellConfig controls host-level overselling behavior // OversellConfig controls host-level overselling behavior
type OversellConfig struct { type OversellConfig struct {
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
RAMOvercommit int `json:"ram_overcommit"` // multiplier RAMOvercommit int `json:"ram_overcommit"` // multiplier
DiskOvercommit int `json:"disk_overcommit"` // multiplier DiskOvercommit int `json:"disk_overcommit"` // multiplier
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
Swappiness int `json:"swappiness"` // 0-100, lower = less swap 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 // Container represents an LXC container configuration
type Container struct { type Container struct {
ID int `json:"id"` ID int `json:"id"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
Template string `json:"template"` LXCName string `json:"lxc_name,omitempty"`
VCPU float64 `json:"vcpu"` Template string `json:"template"`
RAMMB int `json:"ram_mb"` VCPU float64 `json:"vcpu"`
DiskGB int `json:"disk_gb"` RAMMB int `json:"ram_mb"`
NetworkBWMbps int `json:"network_bw_mbps"` DiskGB int `json:"disk_gb"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"` NetworkBWMbps int `json:"network_bw_mbps"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out" MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"` TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedTX int64 `json:"traffic_used_tx"` TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficResetDate string `json:"traffic_reset_date"` TrafficUsedTX int64 `json:"traffic_used_tx"`
IOSpeedMBps int `json:"io_speed_mbps"` TrafficResetDate string `json:"traffic_reset_date"`
Status string `json:"status"` IOSpeedMBps int `json:"io_speed_mbps"`
IP string `json:"ip"` Status string `json:"status"`
IPv6 string `json:"ipv6"` IP string `json:"ip"`
IPv6PrefixLen int `json:"ipv6_prefix_len"` IPv6 string `json:"ipv6"`
IPv6Interface string `json:"ipv6_interface"` IPv6PrefixLen int `json:"ipv6_prefix_len"`
VNCPort int `json:"vnc_port"` IPv6Interface string `json:"ipv6_interface"`
SSHPort int `json:"ssh_port"` VNCPort int `json:"vnc_port"`
SSHPassword string `json:"ssh_password"` SSHPort int `json:"ssh_port"`
PortMappings []PortMapping `json:"port_mappings"` SSHPassword string `json:"ssh_password"`
PortMappingLimit int `json:"port_mapping_limit"` SSHHostKey string `json:"ssh_host_key,omitempty"`
CreatedAt string `json:"created_at"` PortMappings []PortMapping `json:"port_mappings"`
ExpiresAt string `json:"expires_at"` 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}) // LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string { func (c *Container) LxcName() string {
if c.LXCName != "" {
return c.LXCName
}
return fmt.Sprintf("ct-%d", c.ID) return fmt.Sprintf("ct-%d", c.ID)
} }
@@ -126,12 +143,26 @@ func DeleteApiKey(id string) {
type SubUser struct { type SubUser struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` // plaintext for display Password string `json:"password,omitempty"`
PassHash string `json:"pass_hash"` PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"` ContainerNames []string `json:"container_names"`
Token string `json:"token"` ContainerUUIDs []string `json:"container_uuids,omitempty"`
Token string `json:"-"`
AccessCode string `json:"access_code"` AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
TokenVersion int `json:"token_version"`
}
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 // ClicdConfig is the main configuration structure
@@ -153,11 +184,14 @@ type ClicdConfig struct {
Tasks []SavedTask `json:"tasks"` Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"` LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"` EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
} }
var configPath string var configPath string
var AppConfig *ClicdConfig var AppConfig *ClicdConfig
const DefaultSnapshotLimit = 3
func getConfigPath() string { func getConfigPath() string {
if configPath != "" { if configPath != "" {
return configPath return configPath
@@ -245,12 +279,14 @@ func InitConfig() (*ClicdConfig, error) {
Tasks: []SavedTask{}, Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{}, LoginLogs: []SavedLoginLog{},
Oversell: OversellConfig{ Oversell: OversellConfig{
CPUOvercommit: 4, CPUOvercommit: 4,
RAMOvercommit: 1, RAMOvercommit: 1,
DiskOvercommit: 2, DiskOvercommit: 2,
KSMEnabled: true, KSMEnabled: true,
Swappiness: 10, Swappiness: 10,
SubUserSnapshotLimit: 3,
}, },
Snapshots: []Snapshot{},
} }
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
@@ -300,10 +336,25 @@ func InitConfig() (*ClicdConfig, error) {
if AppConfig.Containers == nil { if AppConfig.Containers == nil {
AppConfig.Containers = make([]Container, 0) 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() changed := ensureContainerUUIDs()
if ensureContainerPortMappingLimits() { if ensureContainerPortMappingLimits() {
changed = true changed = true
} }
if ensureContainerSnapshotLimits() {
changed = true
}
if ensureContainerSnapshotScheduleDefaults() {
changed = true
}
if migrateSubUsers() {
changed = true
}
if removeLegacyVNCMappings() { if removeLegacyVNCMappings() {
changed = true changed = true
} }
@@ -316,6 +367,21 @@ func InitConfig() (*ClicdConfig, error) {
return AppConfig, nil 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 { func ensureContainerUUIDs() bool {
changed := false changed := false
used := make(map[string]bool) used := make(map[string]bool)
@@ -351,6 +417,72 @@ func ensureContainerPortMappingLimits() bool {
return changed 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.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 { func removeLegacyVNCMappings() bool {
changed := false changed := false
for i := range AppConfig.Containers { for i := range AppConfig.Containers {
@@ -403,7 +535,10 @@ func AllocateContainerID() int {
func RemoveContainer(id int) bool { func RemoveContainer(id int) bool {
for i, c := range AppConfig.Containers { for i, c := range AppConfig.Containers {
if c.ID == id { if c.ID == id {
removeSubUserContainerAccess(c.Name) removeSubUserContainerAccess(c.Name, c.UUID)
removeContainerSnapshotMetadata(id)
// Clear snapshot schedule for this container
clearContainerSnapshotSchedule(&AppConfig.Containers[i])
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...) AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig() SaveConfig()
return true return true
@@ -412,8 +547,67 @@ func RemoveContainer(id int) bool {
return false return false
} }
func removeSubUserContainerAccess(containerName string) { func clearContainerSnapshotSchedule(c *Container) {
if containerName == "" || len(AppConfig.SubUsers) == 0 { c.SnapshotScheduleEnabled = false
c.SnapshotScheduleIntervalHours = 0
c.SnapshotScheduleTime = ""
c.SnapshotScheduleLastRun = ""
c.SnapshotScheduleNextRun = ""
c.SnapshotScheduleCreatedBy = ""
}
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 return
} }
filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers)) filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers))
@@ -424,10 +618,17 @@ func removeSubUserContainerAccess(containerName string) {
filteredNames = append(filteredNames, name) 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 continue
} }
su.ContainerNames = filteredNames su.ContainerNames = filteredNames
su.ContainerUUIDs = filteredUUIDs
filteredUsers = append(filteredUsers, su) filteredUsers = append(filteredUsers, su)
} }
AppConfig.SubUsers = filteredUsers AppConfig.SubUsers = filteredUsers
@@ -534,6 +735,26 @@ func AddAuditLog(action, target, detail, user string) {
SaveConfig() SaveConfig()
} }
func AddAuditLogFull(action, target, detail, user, ip, userAgent string, success bool, errMsg string) {
s := success
log := AuditLog{
Time: time.Now().Format("2006-01-02 15:04:05"),
Action: action,
Target: target,
Detail: detail,
User: user,
IP: ip,
UserAgent: userAgent,
Success: &s,
Error: errMsg,
}
AppConfig.AuditLogs = append(AppConfig.AuditLogs, log)
if len(AppConfig.AuditLogs) > 500 {
AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:]
}
SaveConfig()
}
// SaveTasks persists the task queue to config // SaveTasks persists the task queue to config
func SaveTasks(tasks []SavedTask) { func SaveTasks(tasks []SavedTask) {
AppConfig.Tasks = tasks AppConfig.Tasks = tasks
+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 { if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil {
return nil, err 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 { if err := m.ApplyIPv6(id); err != nil {
return nil, err return nil, err
} }
@@ -477,6 +483,12 @@ func (m *Manager) ApplyIPv6(id int) error {
config.SaveConfig() 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 { if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil {
return err return err
} }
@@ -485,12 +497,16 @@ func (m *Manager) ApplyIPv6(id int) error {
return nil return nil
} }
cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c", 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))) shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal)))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output)) 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 return nil
} }
@@ -513,6 +529,191 @@ func ensureHostIPv6Routing(ipv6, uplink string) error {
return nil 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) { func ensureIPv6ForwardRules(ipv6 string) {
rules := [][]string{ rules := [][]string{
{"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"}, {"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) { func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run() _ = exec.Command(name, args...).Run()
} }
+278 -22
View File
@@ -1,6 +1,7 @@
package lxc package lxc
import ( import (
"bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
@@ -105,19 +106,23 @@ func (m *Manager) updateAllRates() {
} }
lxcName := c.LxcName() lxcName := c.LxcName()
// Read raw bytes // Cache init PID once per scan so getContainerNetworkBytes / getContainerDiskIOBytes
memUsage := readIntCommand(fmt.Sprintf( // don't each fork lxc-info separately.
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+ initPID := m.getContainerInitPID(lxcName)
"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)))
cpuUsec := uint64(readIntCommand(fmt.Sprintf( // Read memory from cgroup directly (no shell fork)
"(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+ memUsage := readCgroupFile(lxcName,
"cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+ "/sys/fs/cgroup/lxc/%s/memory.current",
"awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName)))) "/sys/fs/cgroup/lxc.payload.%s/memory.current",
"/sys/fs/cgroup/memory/lxc/%s/memory.usage_in_bytes")
rxBytes, txBytes := m.getContainerNetworkBytes(lxcName) // Read cpu usage from cgroup directly (no shell | awk fork)
readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName) 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() now := time.Now()
sample := containerUsageSample{ sample := containerUsageSample{
@@ -216,6 +221,7 @@ type ContainerConfig struct {
IOSpeedMBps int `json:"io_speed_mbps"` IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"` ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"` PortMappingCount int `json:"port_mapping_count"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv6 bool `json:"assign_ipv6"` AssignIPv6 bool `json:"assign_ipv6"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
} }
@@ -226,6 +232,12 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if tmpl == nil { if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID) 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) { if !config.IsValidContainerName(cfg.Name) {
return fmt.Errorf("invalid container name: %s", cfg.Name) return fmt.Errorf("invalid container name: %s", cfg.Name)
@@ -351,6 +363,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
SSHPassword: sshPassword, SSHPassword: sshPassword,
PortMappings: portMappings, PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount, PortMappingLimit: cfg.PortMappingCount,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now, CreatedAt: now,
ExpiresAt: cfg.ExpiresAt, 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. // Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, cfg.TemplateID) 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 { if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err) 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) return fmt.Errorf("container not found: %d", id)
} }
lxcName := c.LxcName() lxcName := c.LxcName()
if c.IPv6 != "" && c.IPv6Interface != "" {
removeHostIPv6Routing(c.IPv6, c.IPv6Interface)
}
if err := m.StopContainer(id); err != nil { if err := m.StopContainer(id); err != nil {
return fmt.Errorf("failed to stop container before destroy: %v", err) return fmt.Errorf("failed to stop container before destroy: %v", err)
@@ -1458,6 +1479,19 @@ func (m *Manager) DestroyContainer(id int) error {
} }
return fmt.Errorf("container still exists after cleanup with status %s", status) return fmt.Errorf("container still exists after cleanup with status %s", status)
} }
// Remove snapshot physical files (by container ID, not lxcName)
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id))
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(snapshotDir)
}
// Also remove any legacy snapshot dir that used lxcName
legacySnapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
if legacySnapshotDir != snapshotDir {
if err := safePathUnder(legacySnapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(legacySnapshotDir)
}
}
if !config.RemoveContainer(id) { if !config.RemoveContainer(id) {
return fmt.Errorf("container destroyed but config entry was not removed: %d", id) return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
@@ -1915,6 +1949,106 @@ func (m *Manager) ListContainers() ([]config.Container, error) {
return containers, nil return containers, nil
} }
// ImportExistingClicdContainers imports existing LXC containers into the CLICD
// config. Native CLICD containers keep ct-{id}; arbitrary LXC names are stored
// in Container.LXCName so Web and CLI can manage the same imported container.
func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
entries, err := os.ReadDir(m.LxcPath)
if err != nil {
return nil, err
}
existingIDs := make(map[int]bool)
existingNames := make(map[string]bool)
existingLXCNames := make(map[string]bool)
maxID := config.AppConfig.NextContainerID - 1
for _, c := range config.AppConfig.Containers {
existingIDs[c.ID] = true
existingNames[c.Name] = true
existingLXCNames[c.LxcName()] = true
if c.ID > maxID {
maxID = c.ID
}
}
re := regexp.MustCompile(`^ct-([0-9]+)$`)
imported := make([]config.Container, 0)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
lxcName := entry.Name()
if existingLXCNames[lxcName] {
continue
}
id := 0
if matches := re.FindStringSubmatch(lxcName); len(matches) == 2 {
if parsed, err := strconv.Atoi(matches[1]); err == nil && parsed > 0 && !existingIDs[parsed] {
id = parsed
}
}
if id == 0 {
id = maxID + 1
for existingIDs[id] {
id++
}
}
name := lxcName
if existingNames[name] {
name = fmt.Sprintf("imported-%d", id)
}
status, err := m.GetContainerStatus(lxcName)
if err != nil || status == "" {
status = "unknown"
}
c := config.Container{
ID: id,
UUID: config.NewContainerUUID(),
Name: name,
LXCName: lxcName,
Template: "imported",
VCPU: 1,
RAMMB: 512,
DiskGB: 10,
NetworkBWMbps: 100,
MonthlyTrafficGB: 1000,
TrafficMode: "total",
Status: status,
CreatedAt: time.Now().Format(time.RFC3339),
PortMappingLimit: 2,
SnapshotLimit: config.DefaultSnapshotLimit,
}
if status == "running" {
if ip, err := m.GetContainerIP(lxcName); err == nil {
c.IP = ip
}
}
config.AppConfig.Containers = append(config.AppConfig.Containers, c)
imported = append(imported, c)
existingIDs[id] = true
existingNames[name] = true
existingLXCNames[lxcName] = true
if id > maxID {
maxID = id
}
}
if len(imported) > 0 {
config.AppConfig.NextContainerID = maxID + 1
if err := config.SaveConfig(); err != nil {
return nil, err
}
}
return imported, nil
}
// ReinstallContainer reinstalls the container OS // ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error { func (m *Manager) ReinstallContainer(id int, templateID string) error {
c := config.FindContainer(id) c := config.FindContainer(id)
@@ -1989,6 +2123,11 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Set root password and pre-configure network/SSH via chroot. // Set root password and pre-configure network/SSH via chroot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, templateID) 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 == "" { if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16) c.SSHPassword = generateRandomString(16)
} }
@@ -2004,6 +2143,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Update template and keep everything else the same // Update template and keep everything else the same
c.Template = templateID c.Template = templateID
c.SSHHostKey = ""
c.Status = "running" c.Status = "running"
config.SaveConfig() config.SaveConfig()
@@ -2119,10 +2259,38 @@ func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) {
if pid == "" { if pid == "" {
return 0, 0 return 0, 0
} }
dir := fmt.Sprintf("/proc/%s/net", pid) return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", 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) // 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) { func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
@@ -2130,10 +2298,22 @@ func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
if pid == "" { if pid == "" {
return 0, 0 return 0, 0
} }
// /proc/PID/io format: "field_name: value" per line data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
// Fields: rchar, wchar, syscr, syscw, read_bytes, write_bytes, cancelled_write_bytes if err != nil {
readBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^read_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid))) return 0, 0
writeBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^write_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid))) }
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 return readBytes, writeBytes
} }
@@ -2146,6 +2326,73 @@ func (m *Manager) getContainerInitPID(lxcName string) string {
return strings.TrimSpace(string(out)) 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). // getContainerUptimeSeconds returns how long the container has been running (in seconds).
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 { func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
pid := m.getContainerInitPID(lxcName) pid := m.getContainerInitPID(lxcName)
@@ -2273,6 +2520,7 @@ func (m *Manager) AccumulateTraffic() {
lastTrafficSnapshotMu.Lock() lastTrafficSnapshotMu.Lock()
defer lastTrafficSnapshotMu.Unlock() defer lastTrafficSnapshotMu.Unlock()
changed := false
for i := range config.AppConfig.Containers { for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i] c := &config.AppConfig.Containers[i]
if c.Status != "running" { if c.Status != "running" {
@@ -2286,17 +2534,25 @@ func (m *Manager) AccumulateTraffic() {
c.TrafficUsedTX = 0 c.TrafficUsedTX = 0
c.TrafficResetDate = currentMonth c.TrafficResetDate = currentMonth
delete(lastTrafficSnapshot, c.LxcName()) delete(lastTrafficSnapshot, c.LxcName())
changed = true
} }
rx, tx := m.getContainerNetworkBytes(c.LxcName()) rx, tx := m.getContainerNetworkBytes(c.LxcName())
prev, exists := lastTrafficSnapshot[c.LxcName()] prev, exists := lastTrafficSnapshot[c.LxcName()]
// Only add the DELTA (increment since last snapshot) // Only add the DELTA (increment since last snapshot)
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes { if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
c.TrafficUsedRX += int64(rx - prev.RXBytes) deltaRX := int64(rx - prev.RXBytes)
c.TrafficUsedTX += int64(tx - prev.TXBytes) 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} lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
} }
config.SaveConfig() if changed {
config.SaveConfig()
}
} }
// GetTrafficInfo returns traffic usage info for a container // GetTrafficInfo returns traffic usage info for a container
+350
View File
@@ -0,0 +1,350 @@
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"))
// Use container ID instead of lxcName to avoid collision when containers are recreated
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), 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)
})
}
+47 -18
View File
@@ -3,13 +3,13 @@ package server
import ( import (
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time"
"clicd/internal/api" "clicd/internal/api"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc"
) )
// webFS holds embedded frontend files // webFS holds embedded frontend files
@@ -18,12 +18,19 @@ var webFS http.FileSystem
// corsMiddleware adds CORS headers // corsMiddleware adds CORS headers
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
w.Header().Set("Access-Control-Allow-Credentials", "true")
if r.Method == http.MethodOptions { 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) w.WriteHeader(http.StatusOK)
return 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 // setupRoutes configures API and static routes
func setupRoutes(mux *http.ServeMux) { func setupRoutes(mux *http.ServeMux) {
// API routes // 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/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard))) mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/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/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell))) mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus))) mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))
@@ -61,6 +98,8 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate))) mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin)) mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin))
mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode)) mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode))
mux.HandleFunc("/api/sub-users", corsMiddleware(api.AdminMiddleware(api.HandleSubUserList)))
mux.HandleFunc("/api/sub-users/", corsMiddleware(api.AdminMiddleware(api.HandleSubUserAction)))
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs))) mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts))) mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck))) mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
@@ -73,6 +112,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.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete))) mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Version (public)
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
// Static files // Static files
if webFS != nil { if webFS != nil {
fs := http.FileServer(webFS) fs := http.FileServer(webFS)
@@ -107,7 +149,6 @@ func setupRoutes(mux *http.ServeMux) {
func Run() error { func Run() error {
// Use embedded frontend files // Use embedded frontend files
webFS = GetEmbeddedFS() webFS = GetEmbeddedFS()
startExpiryMonitor()
mux := http.NewServeMux() mux := http.NewServeMux()
setupRoutes(mux) setupRoutes(mux)
@@ -124,15 +165,3 @@ func Run() error {
return server.ListenAndServe() 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 @@
+17
View File
@@ -0,0 +1,17 @@
package version
var (
Version = "1.0.9"
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) // Start usage monitor (computes CPU/network/disk rates every 5s)
manager.StartUsageMonitor() manager.StartUsageMonitor()
// Start scheduled snapshot scanner.
manager.StartSnapshotScheduler()
// Clean up stale container configs (LXC dir was deleted but config remains) // Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers() config.CleanStaleContainers()
+2 -1
View File
@@ -52,7 +52,8 @@ go mod tidy
go mod download go mod download
# Build for Linux amd64 # 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" echo "Go backend built successfully"
+9 -1
View File
@@ -5,8 +5,16 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLICD - LXC Container Manager</title> <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> </head>
<body class="bg-white text-black"> <body class="bg-white text-black dark:bg-gray-950 dark:text-white">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+8 -2
View File
@@ -10,6 +10,9 @@ import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration' import ApiIntegration from './pages/ApiIntegration'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement' import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots'
import Routing from './pages/Routing'
import SubUserManagement from './pages/SubUserManagement'
import Layout from './components/Layout' import Layout from './components/Layout'
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -17,8 +20,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-white"> <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"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black dark:border-white"></div>
</div> </div>
) )
} }
@@ -57,8 +60,11 @@ function App() {
<Route path="container/:id" element={<ContainerDetail />} /> <Route path="container/:id" element={<ContainerDetail />} />
<Route path="oversell" element={<Oversell />} /> <Route path="oversell" element={<Oversell />} />
<Route path="security" element={<Security />} /> <Route path="security" element={<Security />} />
<Route path="snapshots" element={<Snapshots />} />
<Route path="routing" element={<Routing />} />
<Route path="audit-logs" element={<AuditLogs />} /> <Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} /> <Route path="api-integration" element={<ApiIntegration />} />
<Route path="sub-users" element={<SubUserManagement />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
@@ -7,6 +7,7 @@ interface CreateContainerModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void> onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
existingNames?: string[]
} }
const defaultForm: CreateContainerRequest = { const defaultForm: CreateContainerRequest = {
@@ -24,11 +25,12 @@ const defaultForm: CreateContainerRequest = {
io_speed_mbps: 0, io_speed_mbps: 0,
extra_ports: [], extra_ports: [],
port_mapping_count: 2, port_mapping_count: 2,
snapshot_limit: 3,
assign_ipv6: false, assign_ipv6: false,
expires_at: '', expires_at: '',
} }
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) { export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const dialog = useDialog() const dialog = useDialog()
const [templates, setTemplates] = useState<Template[]>([]) const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -36,6 +38,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
const [form, setForm] = useState<CreateContainerRequest>(defaultForm) const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null) const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null) const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('')
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@@ -82,6 +85,34 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
// SSH port preview (will be allocated sequentially, starting around 22000+) // SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000 const sshPortPreview = 22000
// Find next available batch index to avoid name conflicts
const batchStartIndex = useMemo(() => {
if (batchCount <= 1 || !form.name) return 1
const prefix = `${form.name}-`
let maxIdx = 0
for (const existing of existingNames) {
if (existing.startsWith(prefix)) {
const suffix = existing.slice(prefix.length)
const idx = parseInt(suffix, 10)
if (!isNaN(idx) && idx > maxIdx) {
maxIdx = idx
}
}
}
return maxIdx + 1
}, [form.name, batchCount, existingNames])
const handleNameChange = (value: string) => {
setForm({ ...form, name: value })
if (/\s/.test(value)) {
setNameError('容器名称不能包含空格')
} else if (value && existingNames.includes(value) && batchCount === 1) {
setNameError('该容器名称已存在')
} else {
setNameError('')
}
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.name || !form.template_id) { if (!form.name || !form.template_id) {
dialog.alert('提示', '请填写容器名称并选择系统模板') dialog.alert('提示', '请填写容器名称并选择系统模板')
@@ -92,9 +123,16 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
// Build batch of containers // Build batch of containers
const containers: CreateContainerRequest[] = [] const containers: CreateContainerRequest[] = []
const startIndex = batchStartIndex
for (let i = 0; i < batchCount; i++) { for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : 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) setLoading(true)
@@ -130,17 +168,18 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
<input <input
type="text" type="text"
value={form.name} value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })} onChange={(event) => handleNameChange(event.target.value)}
className={inputClass} className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`}
placeholder="my-container" placeholder="my-container"
required required
/> />
{nameError && <p className="text-xs text-red-500 mt-1">{nameError}</p>}
</Field> </Field>
<Field label="批量创建数量"> <Field label="批量创建数量">
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} /> <NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
</Field> </Field>
</div> </div>
{batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-1 {form.name}-{batchCount}</p>} {batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-{batchStartIndex} {form.name}-{batchStartIndex + batchCount - 1}</p>}
<Field label="系统模板"> <Field label="系统模板">
{templates.length === 0 ? ( {templates.length === 0 ? (
@@ -248,6 +287,15 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
</div> </div>
</Field> </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="到期时间"> <Field label="到期时间">
<div className="relative"> <div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
@@ -325,6 +373,7 @@ function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB
vcpu: clampVCPU(form.vcpu, maxVCPU), vcpu: clampVCPU(form.vcpu, maxVCPU),
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512), ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10), 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) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return ( 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)} /> <Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}> <main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6"> <div className="p-6">
+35 -21
View File
@@ -1,5 +1,6 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useTheme } from '../contexts/ThemeContext'
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w' export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
@@ -45,17 +46,19 @@ export default function ResourceStatsPanel({
charts: ResourceChartConfig[] charts: ResourceChartConfig[]
}) { }) {
return ( return (
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden"> <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 bg-white"> <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"></h2> <h2 className="text-sm font-semibold text-gray-950 dark:text-white"></h2>
<div className="flex items-center gap-1.5"> <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) => ( {(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
<button <button
key={item} key={item}
onClick={() => onRangeChange(item)} onClick={() => onRangeChange(item)}
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${ 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]} {rangeLabels[item]}
@@ -64,7 +67,7 @@ export default function ResourceStatsPanel({
</div> </div>
<button <button
onClick={onRefresh} 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="刷新" title="刷新"
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
@@ -90,11 +93,11 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
<div className={`p-4 ${className}`}> <div className={`p-4 ${className}`}>
<div className="flex items-start justify-between gap-3 mb-2"> <div className="flex items-start justify-between gap-3 mb-2">
<div> <div>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950"> <div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
<span className="text-gray-500">{chart.icon}</span> <span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
<span>{chart.title}</span> <span>{chart.title}</span>
</div> </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>
<div className="grid grid-cols-3 gap-3 text-right"> <div className="grid grid-cols-3 gap-3 text-right">
<Stat label="当前" value={chart.formatValue(chart.current)} /> <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 }) { function Stat({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div>
<div className="text-[10px] text-gray-400">{label}</div> <div className="text-[10px] text-gray-400 dark:text-gray-500">{label}</div>
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div> <div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">{value}</div>
</div> </div>
) )
} }
@@ -132,6 +135,9 @@ function LineAreaChart({
formatValue: (value: number) => string formatValue: (value: number) => string
unitLabel?: string unitLabel?: string
}) { }) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const width = 520 const width = 520
const height = 150 const height = 150
const left = 50 const left = 50
@@ -158,12 +164,20 @@ function LineAreaChart({
const yTicks = [1, 0.5, 0] const yTicks = [1, 0.5, 0]
const xTicks = [0, 0.5, 1] 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 ( return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
<defs> <defs>
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1"> <linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#555" stopOpacity="0.25" /> <stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
<stop offset="100%" stopColor="#555" stopOpacity="0.02" /> <stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
</linearGradient> </linearGradient>
</defs> </defs>
@@ -171,8 +185,8 @@ function LineAreaChart({
const y = top + (1 - tick) * innerHeight const y = top + (1 - tick) * innerHeight
return ( return (
<g key={tick}> <g key={tick}>
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" /> <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="#888"> <text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill={axisStroke}>
{formatValue(maxValue * tick)} {formatValue(maxValue * tick)}
</text> </text>
</g> </g>
@@ -184,8 +198,8 @@ function LineAreaChart({
const ts = minTs + tick * span const ts = minTs + tick * span
return ( return (
<g key={tick}> <g key={tick}>
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" /> <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="#888"> <text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill={axisStroke}>
{formatTime(ts)} {formatTime(ts)}
</text> </text>
</g> </g>
@@ -193,15 +207,15 @@ function LineAreaChart({
})} })}
{unitLabel && ( {unitLabel && (
<text x={left - 45} y={top + 10} fontSize="10" fill="#888"> <text x={left - 45} y={top + 10} fontSize="10" fill={axisStroke}>
{unitLabel} {unitLabel}
</text> </text>
)} )}
<line x1={left} y1={top} x2={left} 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="#888" /> <line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
<polygon points={area} fill="url(#resource-chart-fill)" /> <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> </svg>
) )
} }
+14 -7
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useTheme } from '../contexts/ThemeContext'
interface RingStatProps { interface RingStatProps {
value: number value: number
@@ -10,11 +11,17 @@ interface RingStatProps {
} }
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: 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 radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI const circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100) const percentage = Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference const strokeDashoffset = circumference - (percentage / 100) * circumference
const bgStroke = isDark ? '#374151' : '#f3f4f6'
const progressStroke = isDark ? '#f9fafb' : '#000000'
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}> <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} cy={size / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke="#f3f4f6" stroke={bgStroke}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
/> />
{/* Progress ring */} {/* Progress ring */}
@@ -34,7 +41,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
cy={size / 2} cy={size / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke="#000000" stroke={progressStroke}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={circumference} strokeDasharray={circumference}
@@ -44,12 +51,12 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
</svg> </svg>
{/* Center value */} {/* Center value */}
<div className="absolute inset-0 flex flex-col items-center justify-center"> <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> </div>
<div className="mt-2 text-center"> <div className="mt-2 text-center">
<div className="text-sm font-medium text-gray-800">{label}</div> <div className="text-sm font-medium text-gray-800 dark:text-gray-200">{label}</div>
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>} {subLabel && <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{subLabel}</div>}
</div> </div>
</div> </div>
) )
@@ -96,8 +103,8 @@ export default function RingStats({
const hasSwap = swapTotal > 0 const hasSwap = swapTotal > 0
return ( return (
<div className="bg-white border border-gray-200 rounded-lg p-5"> <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 mb-4"></h2> <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`}> <div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
<RingStat <RingStat
value={cpuPercent} value={cpuPercent}
+107 -24
View File
@@ -1,18 +1,25 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Code2, Code2,
Camera,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
Moon,
Package, Package,
Route,
ScrollText, ScrollText,
Server, Server,
Settings2, Settings2,
ShieldAlert, ShieldAlert,
Sun,
UserCog, UserCog,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { getVersion } from '../services/api'
import AppIcon from './AppIcon' import AppIcon from './AppIcon'
interface SidebarProps { interface SidebarProps {
@@ -24,6 +31,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { logout, isSubUser } = useAuth() 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 = const isContainerPage =
location.pathname.startsWith('/containers') || location.pathname.startsWith('/containers') ||
@@ -31,6 +50,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const isImagesPage = location.pathname.startsWith('/images') const isImagesPage = location.pathname.startsWith('/images')
const isOversellPage = location.pathname.startsWith('/oversell') 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 isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration') const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isSecurityPage = location.pathname.startsWith('/security') const isSecurityPage = location.pathname.startsWith('/security')
@@ -38,27 +59,27 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
return ( return (
<aside <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' 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 && ( {!collapsed && (
<div className="flex items-center gap-2"> <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" /> <AppIcon className="w-5 h-5" />
</div> </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> </div>
)} )}
{collapsed && ( {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" /> <AppIcon className="w-5 h-5" />
</div> </div>
)} )}
<button <button
onClick={onToggle} 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="切换侧边栏" title="切换侧边栏"
> >
{collapsed ? ( {collapsed ? (
@@ -75,8 +96,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/')} onClick={() => navigate('/')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname === '/' location.pathname === '/'
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<LayoutDashboard className="w-4 h-4" /> <LayoutDashboard className="w-4 h-4" />
@@ -88,8 +109,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/containers')} onClick={() => navigate('/containers')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isContainerPage isContainerPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Server className="w-4 h-4" /> <Server className="w-4 h-4" />
@@ -101,8 +122,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/images')} onClick={() => navigate('/images')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isImagesPage isImagesPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Package className="w-4 h-4" /> <Package className="w-4 h-4" />
@@ -116,8 +137,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/oversell')} onClick={() => navigate('/oversell')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isOversellPage isOversellPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Settings2 className="w-4 h-4" /> <Settings2 className="w-4 h-4" />
@@ -128,32 +149,68 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/security')} onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSecurityPage isSecurityPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<ShieldAlert className="w-4 h-4" /> <ShieldAlert className="w-4 h-4" />
{!collapsed && <span></span>} {!collapsed && <span></span>}
</button> </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 <button
onClick={() => navigate('/audit-logs')} onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isAuditLogsPage isAuditLogsPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<ScrollText className="w-4 h-4" /> <ScrollText className="w-4 h-4" />
{!collapsed && <span></span>} {!collapsed && <span></span>}
</button> </button>
<button
onClick={() => navigate('/sub-users')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname.startsWith('/sub-users')
? '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" />
{!collapsed && <span></span>}
</button>
<button <button
onClick={() => navigate('/api-integration')} onClick={() => navigate('/api-integration')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isApiIntegrationPage isApiIntegrationPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Code2 className="w-4 h-4" /> <Code2 className="w-4 h-4" />
@@ -164,8 +221,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSettingsPage isSettingsPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<UserCog className="w-4 h-4" /> <UserCog className="w-4 h-4" />
@@ -175,10 +232,36 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
)} )}
</nav> </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 <button
onClick={logout} 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" /> <LogOut className="w-4 h-4" />
{!collapsed && <span>退</span>} {!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 [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const buildWebSSHUrl = (ticket: string) => { const buildWebSSHUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ const params = new URLSearchParams({
container: containerName, container: containerName,
ticket,
}) })
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
} }
@@ -106,7 +105,7 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
return return
} }
const ws = new WebSocket(buildWebSSHUrl(ticket)) const ws = new WebSocket(buildWebSSHUrl(), [`clicd-ticket.${ticket}`])
ws.binaryType = 'arraybuffer' ws.binaryType = 'arraybuffer'
wsRef.current = ws wsRef.current = ws
+1 -4
View File
@@ -41,9 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (savedToken) { if (savedToken) {
const payload = decodeTokenPayload(savedToken) const payload = decodeTokenPayload(savedToken)
const nextUsername = payload?.username || payload?.sub_user || savedUsername || null const nextUsername = payload?.username || payload?.sub_user || savedUsername || null
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0 const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) ? payload.container_uuids : []
? payload.container_uuids
: Array.isArray(payload?.container_names) ? payload.container_names : []
setToken(savedToken) setToken(savedToken)
setUsername(nextUsername) setUsername(nextUsername)
@@ -123,7 +121,6 @@ export function useAuth() {
type TokenPayload = { type TokenPayload = {
username?: string username?: string
sub_user?: string sub_user?: string
container_names?: string[]
container_uuids?: 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; color: #000000;
} }
/* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #888; background: #888;
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #555; 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 { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { DialogProvider } from './components/Dialog' import { DialogProvider } from './components/Dialog'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <ThemeProvider>
<DialogProvider> <AuthProvider>
<App /> <DialogProvider>
</DialogProvider> <App />
</AuthProvider> </DialogProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
) )
+6 -14
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react' import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
import api, { APIResponse } from '../services/api' import api, { APIResponse } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface ApiKeyItem { interface ApiKeyItem {
id: string id: string
@@ -62,21 +63,12 @@ export default function ApiIntegration() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
const copyKey = () => { const copyKey = async () => {
try { const copied = await copyToClipboard(newKey)
navigator.clipboard.writeText(newKey) if (copied) {
} catch { setCopiedKey(true)
const ta = document.createElement('textarea') setTimeout(() => setCopiedKey(false), 2000)
ta.value = newKey
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
} }
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
} }
return ( return (
+390 -55
View File
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { import {
ArrowLeft, ArrowLeft,
Camera,
Clock,
Copy, Copy,
Cpu, Cpu,
HardDrive, HardDrive,
@@ -29,9 +31,12 @@ import {
Container, Container,
ContainerUsage, ContainerUsage,
createSubUser, createSubUser,
createContainerSnapshot,
deleteContainer, deleteContainer,
deleteContainerSnapshot,
deletePortMapping, deletePortMapping,
getContainer, getContainer,
getContainerSnapshots,
getContainerUsage, getContainerUsage,
getHostInfo, getHostInfo,
getTrafficInfo, getTrafficInfo,
@@ -44,8 +49,13 @@ import {
restartContainer, restartContainer,
startContainer, startContainer,
stopContainer, stopContainer,
Snapshot,
SnapshotSchedule,
Template, Template,
updateContainerExpiry, updateContainerExpiry,
updateSnapshotQuota,
updateSnapshotSchedule,
restoreContainerSnapshot,
resetTraffic, resetTraffic,
updateTrafficLimit, updateTrafficLimit,
updateResourceLimit, updateResourceLimit,
@@ -56,6 +66,7 @@ import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import WebSSHViewer from '../components/WebSSHViewer' import WebSSHViewer from '../components/WebSSHViewer'
import { RingStat } from '../components/RingStats' import { RingStat } from '../components/RingStats'
import { copyToClipboard } from '../utils/clipboard'
import ResourceStatsPanel, { import ResourceStatsPanel, {
ChartPoint, ChartPoint,
ResourceChartConfig, ResourceChartConfig,
@@ -125,6 +136,15 @@ export default function ContainerDetail() {
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 }) const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
const [savingResource, setSavingResource] = useState(false) const [savingResource, setSavingResource] = useState(false)
const [showPassword, setShowPassword] = 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 () => { const fetchContainer = useCallback(async () => {
if (!containerIdentifier) return if (!containerIdentifier) return
@@ -142,6 +162,21 @@ export default function ContainerDetail() {
} }
}, [containerIdentifier, isSubUser]) }, [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) => { const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
if (!containerIdentifier || !currentContainer) return if (!containerIdentifier || !currentContainer) return
@@ -198,6 +233,10 @@ export default function ContainerDetail() {
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [fetchUsage]) }, [fetchUsage])
useEffect(() => {
if (showSnapshots) fetchSnapshots()
}, [showSnapshots, fetchSnapshots])
// Poll task status for this container // Poll task status for this container
useEffect(() => { useEffect(() => {
if (!containerIdentifier) return if (!containerIdentifier) return
@@ -478,20 +517,110 @@ export default function ContainerDetail() {
} }
} }
const copyText = async (text: string) => { const handleCreateSnapshot = async () => {
try { if (!containerIdentifier) return
await copyText(text) if (isSubUser && snapshots.length >= snapshotQuota) {
} catch { await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
// Fallback for HTTP (non-secure context) return
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
} }
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) => {
await copyToClipboard(text)
} }
if (loading) { if (loading) {
@@ -536,7 +665,6 @@ export default function ContainerDetail() {
const managementUrl = subUser?.access_code const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: '' : ''
const managementPassword = subUser?.password || ''
const charts: ResourceChartConfig[] = [ const charts: ResourceChartConfig[] = [
{ {
title: 'CPU 使用率', title: 'CPU 使用率',
@@ -641,6 +769,10 @@ export default function ContainerDetail() {
NAT NAT
</ActionButton> </ActionButton>
</> </>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}>
<Camera className="w-3.5 h-3.5" />
</ActionButton>
{!isSubUser && ( {!isSubUser && (
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}> <ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
<RefreshCw className="w-3.5 h-3.5" /> <RefreshCw className="w-3.5 h-3.5" />
@@ -846,6 +978,171 @@ export default function ContainerDetail() {
</Modal> </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 && ( {showNat && (
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={ <Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={
!isSubUser && canAddMapping && !showNatAdd && ( !isSubUser && canAddMapping && !showNatAdd && (
@@ -954,55 +1251,21 @@ export default function ContainerDetail() {
</Modal> </Modal>
)} )}
{showSubUser && subUser && false && (
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
</div>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black break-all">{managementUrl}</span>
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{subUser?.username}</span>
<button onClick={() => copyText(subUser?.username || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{managementPassword}</span>
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
</div>
<p className="text-xs text-gray-400"> token使</p>
</div>
</Modal>
)}
{showSubUser && subUser && ( {showSubUser && subUser && (
<Modal title="管理链接" onClose={() => setShowSubUser(false)}> <Modal title="管理链接" onClose={() => setShowSubUser(false)}>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3"> <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span> <span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black break-all">{managementUrl}</span> <span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl}</span>
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button> <button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
</div> </div>
</div> </div>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span> <span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{managementPassword}</span> <span className="font-mono text-xs text-black dark:text-white">{subUser.password || ''}</span>
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button> <button onClick={() => copyText(subUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
</div> </div>
</div> </div>
</div> </div>
@@ -1229,6 +1492,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 }) { 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) { if (mappings.length === 0) {
return <p className="text-sm text-gray-400"></p> return <p className="text-sm text-gray-400"></p>
@@ -1389,6 +1711,19 @@ function formatExpiration(value?: string): string {
return value.length >= 10 ? value.slice(0, 10) : value 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 { function formatRate(bytesPerSecond: number): string {
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
+8 -1
View File
@@ -401,7 +401,7 @@ export default function Containers() {
</div> </div>
)} )}
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} /> <CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} existingNames={containers.map(c => c.name)} />
{showTasks && ( {showTasks && (
<TaskQueueModal <TaskQueueModal
tasks={tasks} tasks={tasks}
@@ -538,8 +538,15 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
ssh_password: '', ssh_password: '',
port_mappings: [], port_mappings: [],
port_mapping_limit: 2, port_mapping_limit: 2,
snapshot_limit: cfg.snapshot_limit || 3,
created_at: '', created_at: '',
expires_at: cfg.expires_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, isPlaceholder: true,
} }
} }
+359
View File
@@ -0,0 +1,359 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Network, RefreshCw, Route, Search, Server, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } 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 [nat4Search, setNat4Search] = useState('')
const [ipv6Search, setIPv6Search] = useState('')
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])
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Assignments = routing?.ipv6_assignments || []
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
// Filter helpers
const matchesNat4Search = (m: NAT4Route, query: string) => {
if (!query) return true
const q = query.toLowerCase()
return (
String(m.host_port).includes(q) ||
String(m.container_port).includes(q) ||
m.container_name.toLowerCase().includes(q) ||
m.lxc_name.toLowerCase().includes(q) ||
(m.ip || '').toLowerCase().includes(q)
)
}
const matchesIPv6Search = (item: IPv6Route, query: string) => {
if (!query) return true
const q = query.toLowerCase()
return (
(item.address || '').toLowerCase().includes(q) ||
item.container_name.toLowerCase().includes(q) ||
item.lxc_name.toLowerCase().includes(q)
)
}
const filteredNat4 = useMemo(() => nat4Mappings.filter(m => matchesNat4Search(m, nat4Search)), [nat4Mappings, nat4Search])
const filteredIPv6 = useMemo(() => ipv6Assignments.filter(m => matchesIPv6Search(m, ipv6Search)), [ipv6Assignments, ipv6Search])
// Reset page on search change
useEffect(() => { setNat4Page(1) }, [nat4Search])
useEffect(() => { setIPv6Page(1) }, [ipv6Search])
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 pageSize = 10
const nat4TotalPages = Math.max(1, Math.ceil(filteredNat4.length / pageSize))
const ipv6TotalPages = Math.max(1, Math.ceil(filteredIPv6.length / pageSize))
const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
const pagedIPv6Assignments = filteredIPv6.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 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-black dark:text-white">NAT4 </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{nat4Search ? `搜索 "${nat4Search}" 结果 ${filteredNat4.length} 条,` : ''} {nat4Mappings.length}
</div>
</div>
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
value={nat4Search}
onChange={e => setNat4Search(e.target.value)}
placeholder="搜索端口/容器..."
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
/>
{nat4Search && (
<button onClick={() => setNat4Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-3 h-3" />
</button>
)}
</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={filteredNat4.length}
pageSize={pageSize}
onPageChange={setNat4Page}
/>
</>
)}
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-black dark:text-white">IPv6 </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''} {ipv6Assignments.length}
</div>
</div>
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
value={ipv6Search}
onChange={e => setIPv6Search(e.target.value)}
placeholder="搜索地址/容器..."
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
/>
{ipv6Search && (
<button onClick={() => setIPv6Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-3 h-3" />
</button>
)}
</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={filteredIPv6.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
}
+141
View File
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useState } from 'react'
import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function Snapshots() {
const navigate = useNavigate()
const dialog = useDialog()
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [deleting, setDeleting] = useState<string | null>(null)
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])
const handleDelete = async (snapshot: Snapshot) => {
const confirmed = await dialog.confirm(
'删除快照',
`确认删除容器 ${snapshot.container_name} 的快照吗?此操作不可恢复。`
)
if (!confirmed) return
setDeleting(snapshot.id)
try {
await deleteContainerSnapshot(snapshot.container_id, snapshot.id)
setSnapshots(prev => prev.filter(s => s.id !== snapshot.id))
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('删除失败', error.response?.data?.message || '请稍后重试')
} finally {
setDeleting(null)
}
}
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>
<th className="px-4 py-3 text-center 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>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleDelete(snapshot)}
disabled={deleting === snapshot.id}
className="inline-flex items-center justify-center p-1.5 rounded text-red-500 hover:bg-red-50 transition-colors disabled:opacity-50"
title="删除快照"
>
<Trash2 className="h-4 w-4" />
</button>
</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`
}
+328
View File
@@ -0,0 +1,328 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
import { useDialog } from '../components/Dialog'
import api, { AuditLog, LoginLog } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface SubUserItem {
id: string
username: string
container_names: string[]
container_uuids: string[]
container_name: string
container_uuid: string
access_code: string
password?: string
created_at: string
last_login: string
last_login_ip: string
last_login_ua: string
}
interface AuditLogExt extends AuditLog {
ip?: string
user_agent?: string
success?: boolean
error?: string
}
export default function SubUserManagement() {
const dialog = useDialog()
const [users, setUsers] = useState<SubUserItem[]>([])
const [loading, setLoading] = useState(true)
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
const [modalTitle, setModalTitle] = useState('')
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
const [rotatingPassword, setRotatingPassword] = useState(false)
const fetchUsers = useCallback(async () => {
try {
const res = await api.get<{ success: boolean; data: SubUserItem[] }>('/sub-users')
setUsers(res.data.data || [])
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchUsers() }, [fetchUsers])
const managementUrl = (user: SubUserItem) => `${window.location.origin}/login?code=${user.access_code}`
const copyText = async (text: string) => {
await copyToClipboard(text)
}
const rotatePassword = async (user: SubUserItem) => {
setRotatingPassword(true)
try {
const res = await api.post(`/sub-users/${user.id}/rotate-password`)
const data = res.data.data
const updatedUser = {
...user,
username: data?.username || user.username,
access_code: data?.access_code || user.access_code,
password: data?.password || '',
}
setUsers((prev) => prev.map((item) => (item.id === user.id ? updatedUser : item)))
setPasswordUser(updatedUser)
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('轮换失败', error.response?.data?.message || '请稍后重试')
} finally {
setRotatingPassword(false)
}
}
const showAuditLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
setAuditLogs(res.data.data || [])
setLoginLogs(null)
setModalTitle(`${user.username} - 操作日志`)
} catch {
dialog.alert('错误', '获取操作日志失败')
}
}
const showLoginLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/login-logs`)
setLoginLogs(res.data.data || [])
setAuditLogs(null)
setModalTitle(`${user.username} - 登录日志`)
} catch {
dialog.alert('错误', '获取登录日志失败')
}
}
const closeModal = () => {
setAuditLogs(null)
setLoginLogs(null)
}
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>
<h1 className="text-xl font-semibold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> {users.length} </p>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
{users.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 dark:bg-gray-800">
<UserCog className="h-7 w-7 text-gray-400" />
</div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300"></div>
</div>
) : (
<table className="w-full min-w-[820px] text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400">
<tr>
<th className="px-4 py-3 text-left font-medium w-12">#</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium">UUID</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{users.map((user, index) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-3 text-gray-400 dark:text-gray-500">{index + 1}</td>
<td className="px-4 py-3 font-medium text-black dark:text-white">{user.container_name || '-'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{user.container_uuid || '-'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{user.last_login ? (
<div>
<div className="text-xs">{user.last_login}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{user.last_login_ip}</div>
</div>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => setPasswordUser(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 transition-colors"
title="查看密码"
>
<KeyRound className="w-3.5 h-3.5" />
</button>
<button
onClick={() => showAuditLogs(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors"
title="查看操作日志"
>
<ScrollText className="w-3.5 h-3.5" />
</button>
<button
onClick={() => showLoginLogs(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
title="查看登录日志"
>
<LogIn className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{passwordUser && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-lg overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-black dark:text-white"></h3>
<div className="flex items-center gap-2">
<button
onClick={() => rotatePassword(passwordUser)}
disabled={rotatingPassword}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-amber-700 bg-amber-50 hover:bg-amber-100 dark:text-amber-300 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 disabled:opacity-50"
title="轮换密码"
>
<RefreshCw className={`w-3.5 h-3.5 ${rotatingPassword ? 'animate-spin' : ''}`} />
{rotatingPassword ? '轮换中...' : '轮换密码'}
</button>
<button onClick={() => setPasswordUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-5">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<span className="min-w-0 text-right font-medium text-black dark:text-white break-all">{passwordUser.username}</span>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl(passwordUser)}</span>
<button onClick={() => copyText(managementUrl(passwordUser))} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black dark:text-white break-all">
{passwordUser.password || '未保存,请轮换生成新密码'}
</span>
{passwordUser.password && (
<button onClick={() => copyText(passwordUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Log Modal */}
{(auditLogs || loginLogs) && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-black dark:text-white">{modalTitle}</h3>
<button onClick={closeModal} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto flex-1">
{auditLogs && (
<table className="w-full text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
<tr>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left">IP</th>
<th className="px-4 py-2 text-left">UA</th>
<th className="px-4 py-2 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{auditLogs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400"></td></tr>
) : auditLogs.map((log, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip || '-'}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[200px] truncate" title={log.user_agent}>{log.user_agent || '-'}</td>
<td className="px-4 py-2 text-center">
{log.success !== undefined ? (
log.success ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400" title={log.error}>{log.error ? '失败' : '失败'}</span>
)
) : (
<span className="text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{loginLogs && (
<table className="w-full text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
<tr>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left"> IP</th>
<th className="px-4 py-2 text-left">UA</th>
<th className="px-4 py-2 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loginLogs.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400"></td></tr>
) : loginLogs.map((log, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[250px] truncate" title={log.user_agent}>{log.user_agent}</td>
<td className="px-4 py-2 text-center">
{log.success ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div>
)
}
+116 -2
View File
@@ -71,8 +71,15 @@ export interface Container {
ssh_password: string ssh_password: string
port_mappings: PortMapping[] port_mappings: PortMapping[]
port_mapping_limit: number port_mapping_limit: number
snapshot_limit: number
created_at: string created_at: string
expires_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 { export interface Template {
@@ -100,6 +107,7 @@ export interface CreateContainerRequest {
io_speed_mbps: number io_speed_mbps: number
extra_ports: number[] extra_ports: number[]
port_mapping_count: number port_mapping_count: number
snapshot_limit: number
assign_ipv6: boolean assign_ipv6: boolean
expires_at: string expires_at: string
} }
@@ -189,6 +197,14 @@ export interface LoginLog {
success: boolean success: boolean
} }
export interface AuditLog {
time: string
action: string
target: string
detail: string
user: string
}
export const getLoginLogs = () => export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs') api.get<APIResponse<LoginLog[]>>('/login-logs')
@@ -275,6 +291,45 @@ export const getIPv6Status = () =>
export const assignIPv6 = (id: ContainerIdentifier) => export const assignIPv6 = (id: ContainerIdentifier) =>
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`) 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 // Templates
export const getTemplates = () => export const getTemplates = () =>
api.get<APIResponse<Template[]>>('/templates') api.get<APIResponse<Template[]>>('/templates')
@@ -354,6 +409,62 @@ export const getOversellStatus = () =>
export const reclaimMemory = () => export const reclaimMemory = () =>
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim') 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 // WebSSH URL generator
export const getWebSSHUrl = (containerName: string) => { export const getWebSSHUrl = (containerName: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -390,10 +501,9 @@ export const batchAction = (action: string, containers: number[], templateId?: s
export interface SubUser { export interface SubUser {
id: string id: string
username: string username: string
password: string password?: string
container_names: string[] container_names: string[]
container_uuids?: string[] container_uuids?: string[]
token: string
access_code: string access_code: string
created_at: string created_at: string
} }
@@ -451,4 +561,8 @@ export const getSecuritySummary = () =>
export const createWebSSHTicket = (containerName: string) => export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName }) api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
// Version
export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version')
export default api export default api
+44
View File
@@ -0,0 +1,44 @@
export async function copyToClipboard(text: string): Promise<boolean> {
if (!text) return false
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through for non-secure HTTP origins where Clipboard API is blocked.
}
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '0'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
const selection = document.getSelection()
const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null
document.body.appendChild(textarea)
textarea.focus({ preventScroll: true })
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
let copied = false
try {
copied = document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
if (selection && selectedRange) {
selection.removeAllRanges()
selection.addRange(selectedRange)
}
}
return copied
}
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: 'class',
content: [ content: [
"./index.html", "./index.html",
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

+528 -105
View File
@@ -1,105 +1,470 @@
#!/bin/bash #!/bin/sh
set -euo pipefail set -eu
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
ASSET="clicd-linux-amd64.tar.gz"
ACTION="${1:-install}"
echo "=====================================" echo "====================================="
echo " CLICD Installation" echo " CLICD Installer"
echo "=====================================" echo "====================================="
if [ "$EUID" -ne 0 ]; then log() {
echo "[clicd] $*"
}
die() {
echo "ERROR: $*" >&2
exit 1
}
has_cmd() {
command -v "$1" >/dev/null 2>&1
}
is_systemd() {
has_cmd systemctl && [ -d /run/systemd/system ]
}
is_openrc() {
has_cmd rc-service && has_cmd rc-update
}
if [ "$(id -u)" -ne 0 ]; then
echo "Please run as root: sudo ./install.sh" echo "Please run as root: sudo ./install.sh"
echo "Or: curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo bash" echo "Or: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
echo "Uninstall: curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
exit 1 exit 1
fi fi
if [ ! -f "./clicd" ]; then OS_ID="unknown"
REPO="${CLICD_REPO:-MengMengCode/CLICD}" OS_LIKE=""
VERSION="${CLICD_VERSION:-latest}" if [ -r /etc/os-release ]; then
ASSET="clicd-linux-amd64.tar.gz" . /etc/os-release
OS_ID="${ID:-unknown}"
if [ "$VERSION" = "latest" ]; then OS_LIKE="${ID_LIKE:-}"
DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
else
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}"
fi
echo "clicd binary not found in current directory."
echo "Downloading release package: ${DOWNLOAD_URL}"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
elif command -v wget >/dev/null 2>&1; then
wget -O "$TMP_DIR/$ASSET" "$DOWNLOAD_URL"
else
echo "ERROR: curl or wget is required to download the release package."
exit 1
fi
tar -xzf "$TMP_DIR/$ASSET" -C "$TMP_DIR"
cd "$TMP_DIR/clicd-linux-amd64"
fi fi
if ! command -v lxc-create >/dev/null 2>&1; then usage() {
echo "LXC is not installed. Installing dependencies..." cat << EOF
if command -v apt-get >/dev/null 2>&1; then Usage:
apt-get update ./install.sh
apt-get install -y lxc lxc-templates bridge-utils xz-utils quota ./install.sh uninstall
elif command -v yum >/dev/null 2>&1; then
yum install -y epel-release Environment:
yum install -y lxc lxc-templates xz quota CLICD_REPO=owner/repo
elif command -v dnf >/dev/null 2>&1; then CLICD_VERSION=latest|v1.0.0
dnf install -y lxc lxc-templates xz quota
else Examples:
echo "Could not detect package manager. Please install LXC manually." curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
exit 1 curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
EOF
}
remove_path() {
path="$1"
if [ ! -e "$path" ] && [ ! -L "$path" ]; then
return
fi fi
fi rm -rf "$path"
log "Removed $path"
}
# Setup subordinate UID/GID for unprivileged containers unmount_path_tree() {
echo "Setting up subordinate UID/GID ranges..." path="$1"
grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid if [ ! -e "$path" ]; then
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid return
fi
# Enable ext4 project quota if supported if has_cmd findmnt; then
if tune2fs -l /dev/sda1 2>/dev/null | grep -q 'Filesystem features'; then findmnt -R -n -o TARGET "$path" 2>/dev/null | sort -r | while IFS= read -r mountpoint; do
echo "Enabling ext4 project quota..." [ -n "$mountpoint" ] || continue
mkdir -p /etc/initramfs-tools/hooks /etc/initramfs-tools/scripts/local-premount umount -R -l "$mountpoint" >/dev/null 2>&1 || umount -l "$mountpoint" >/dev/null 2>&1 || true
done
# Hook to copy tune2fs into initramfs fi
cat > /etc/initramfs-tools/hooks/tune2fs-hook << 'HOOK'
#!/bin/sh
PREREQ=""
prereqs() { echo "$PREREQ"; }
case "$1" in prereqs) prereqs; exit 0;; esac
. /usr/share/initramfs-tools/hook-functions
copy_exec /sbin/tune2fs /sbin/tune2fs
copy_exec /usr/sbin/setquota /usr/sbin/setquota
HOOK
chmod +x /etc/initramfs-tools/hooks/tune2fs-hook
# Script to run tune2fs before mount
cat > /etc/initramfs-tools/scripts/local-premount/prjquota << 'SCRIPT'
#!/bin/sh
PREREQ=""
prereqs() { echo "$PREREQ"; }
case "$1" in prereqs) prereqs; exit 0;; esac
/sbin/tune2fs -O project -Q prjquota /dev/sda1 2>/dev/null
SCRIPT
chmod +x /etc/initramfs-tools/scripts/local-premount/prjquota
update-initramfs -u -k all 2>/dev/null || true
# Add prjquota to fstab if not already there
grep -q 'prjquota' /etc/fstab 2>/dev/null || sed -i 's|ext4 rw,|ext4 rw,prjquota,|' /etc/fstab
fi
cp ./clicd /usr/local/bin/clicd umount -R -l "$path/rootfs" >/dev/null 2>&1 || umount -l "$path/rootfs" >/dev/null 2>&1 || true
chmod +x /usr/local/bin/clicd umount -R -l "$path" >/dev/null 2>&1 || umount -l "$path" >/dev/null 2>&1 || true
echo "Installed binary: /usr/local/bin/clicd" }
cat > /etc/systemd/system/clicd.service << 'EOF' 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..."
if has_cmd systemctl; then
systemctl stop clicd >/dev/null 2>&1 || true
systemctl disable clicd >/dev/null 2>&1 || true
fi
if has_cmd rc-service; then
rc-service clicd stop >/dev/null 2>&1 || true
fi
if has_cmd rc-update; then
rc-update del clicd default >/dev/null 2>&1 || true
fi
log "Destroying LXC containers under /var/lib/lxc..."
for container_dir in /var/lib/lxc/*; do
[ -d "$container_dir" ] || continue
remove_lxc_container_dir "$container_dir"
done
remove_path /etc/systemd/system/clicd.service
remove_path /etc/init.d/clicd
remove_path /usr/local/bin/clicd
remove_path /etc/sysctl.d/99-clicd.conf
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
if has_cmd systemctl; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl reset-failed clicd >/dev/null 2>&1 || true
fi
if has_cmd sysctl; then
sysctl --system >/dev/null 2>&1 || true
fi
echo ""
echo "====================================="
echo " CLICD Uninstalled"
echo "====================================="
echo " Removed service, binary, config, containers, and LXC image cache."
echo "====================================="
}
case "$ACTION" in
install|"")
;;
uninstall|remove)
uninstall_clicd
exit 0
;;
-h|--help|help)
usage
exit 0
;;
*)
die "Unknown action: $ACTION"
;;
esac
install_apk() {
log "Installing dependencies with apk..."
apk update
apk add --no-cache \
ca-certificates \
curl \
wget \
tar \
gzip \
xz \
lxc \
lxc-download \
lxc-openrc \
lxc-bridge \
lxc-templates \
bridge-utils \
iproute2 \
iptables \
dnsmasq
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg"
done
}
install_apt() {
log "Installing dependencies with apt..."
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y \
ca-certificates \
curl \
wget \
tar \
gzip \
xz-utils \
lxc \
lxc-templates \
lxcfs \
bridge-utils \
uidmap \
iproute2 \
iptables \
conntrack \
quota \
e2fsprogs \
xfsprogs \
dnsmasq-base
}
enable_el_repos() {
if has_cmd dnf; then
dnf install -y 'dnf-command(config-manager)' >/dev/null 2>&1 || true
dnf install -y epel-release || true
dnf config-manager --set-enabled crb >/dev/null 2>&1 || true
dnf config-manager --set-enabled powertools >/dev/null 2>&1 || true
elif has_cmd yum; then
yum install -y yum-utils >/dev/null 2>&1 || true
yum install -y epel-release || true
yum-config-manager --enable powertools >/dev/null 2>&1 || true
fi
}
install_dnf() {
log "Installing dependencies with dnf..."
enable_el_repos
dnf install -y \
ca-certificates \
curl \
wget \
tar \
gzip \
xz \
lxc \
lxc-templates \
bridge-utils \
iproute \
iptables \
conntrack-tools \
shadow-utils \
quota \
e2fsprogs \
xfsprogs \
dnsmasq
dnf install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs"
}
install_yum() {
log "Installing dependencies with yum..."
enable_el_repos
yum install -y \
ca-certificates \
curl \
wget \
tar \
gzip \
xz \
lxc \
lxc-templates \
bridge-utils \
iproute \
iptables \
conntrack-tools \
shadow-utils \
quota \
e2fsprogs \
xfsprogs \
dnsmasq
yum install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs"
}
install_dependencies() {
case "$OS_ID" in
ubuntu|debian)
install_apt
;;
alpine)
install_apk
;;
centos|rhel|rocky|almalinux|fedora)
if has_cmd dnf; then
install_dnf
elif has_cmd yum; then
install_yum
else
die "dnf/yum not found on $OS_ID"
fi
;;
*)
if has_cmd apt-get; then
install_apt
elif has_cmd apk; then
install_apk
elif has_cmd dnf; then
install_dnf
elif has_cmd yum; then
install_yum
else
die "Unsupported Linux distribution: ${OS_ID} ${OS_LIKE}"
fi
;;
esac
has_cmd lxc-create || die "lxc-create is still missing after dependency installation."
has_cmd iptables || die "iptables is still missing after dependency installation."
has_cmd ip || die "iproute2/ip command is still missing after dependency installation."
}
configure_kernel_networking() {
log "Enabling kernel forwarding settings..."
cat > /etc/sysctl.d/99-clicd.conf << 'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
net.bridge.bridge-nf-call-iptables = 0
net.bridge.bridge-nf-call-ip6tables = 0
EOF
modprobe br_netfilter >/dev/null 2>&1 || true
sysctl --system >/dev/null 2>&1 || true
}
setup_lxc_services() {
log "Configuring LXC services..."
if is_systemd; then
systemctl enable --now lxcfs >/dev/null 2>&1 || true
systemctl enable --now lxc-net >/dev/null 2>&1 || true
systemctl enable --now lxc >/dev/null 2>&1 || true
return
fi
if is_openrc; then
rc-update add cgroups default >/dev/null 2>&1 || true
rc-service cgroups start >/dev/null 2>&1 || true
rc-update add lxc default >/dev/null 2>&1 || true
rc-service lxc start >/dev/null 2>&1 || true
rc-update add lxcfs default >/dev/null 2>&1 || true
rc-service lxcfs start >/dev/null 2>&1 || true
return
fi
die "No supported service manager found. CLICD supports systemd or OpenRC."
}
setup_subids() {
log "Setting up subordinate UID/GID ranges..."
touch /etc/subuid /etc/subgid
grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
}
try_enable_project_quota() {
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
log "Project quota auto-enable skipped for root filesystem: ${root_fs:-unknown}"
return
fi
if ! has_cmd tune2fs; then
log "Project quota auto-enable skipped because tune2fs is unavailable."
return
fi
if tune2fs -l "$root_src" 2>/dev/null | grep -q 'project'; then
log "Ext4 project quota support already appears to be enabled."
return
fi
log "Ext4 project quota is not enabled. Disk limits will fall back to loopback images."
}
download_release_if_needed() {
if [ -f "./clicd" ]; then
return
fi
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
download_url="https://github.com/${REPO}/releases/latest/download/${ASSET}"
else
download_url="https://github.com/${REPO}/releases/download/${CLICD_INSTALL_VERSION}/${ASSET}"
fi
log "clicd binary not found in current directory."
log "Downloading release package: ${download_url}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' 0
if has_cmd curl; then
curl -fL "$download_url" -o "$tmp_dir/$ASSET"
elif has_cmd wget; then
wget -O "$tmp_dir/$ASSET" "$download_url"
else
die "curl or wget is required to download the release package."
fi
tar -xzf "$tmp_dir/$ASSET" -C "$tmp_dir"
cd "$tmp_dir/clicd-linux-amd64"
[ -f "./clicd" ] || die "Downloaded release package did not contain clicd."
}
install_binary() {
if has_cmd systemctl; then
systemctl stop clicd >/dev/null 2>&1 || true
fi
if has_cmd rc-service; then
rc-service clicd stop >/dev/null 2>&1 || true
fi
tmp_bin="/usr/local/bin/clicd.new.$$"
cp ./clicd "$tmp_bin"
chmod +x "$tmp_bin"
mv -f "$tmp_bin" /usr/local/bin/clicd
chmod +x /usr/local/bin/clicd
log "Installed binary: /usr/local/bin/clicd"
}
install_systemd_service() {
cat > /etc/systemd/system/clicd.service << 'EOF'
[Unit] [Unit]
Description=CLICD - LXC Container Manager Description=CLICD - LXC Container Manager
After=network.target lxc.service After=network.target lxc.service
@@ -115,23 +480,81 @@ Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
systemctl daemon-reload systemctl daemon-reload
systemctl enable clicd systemctl enable clicd
systemctl restart clicd systemctl restart clicd
}
install_openrc_service() {
cat > /etc/init.d/clicd << 'EOF'
#!/sbin/openrc-run
name="CLICD"
description="CLICD - LXC Container Manager"
command="/usr/local/bin/clicd"
command_args="server"
command_background=true
pidfile="/run/clicd.pid"
output_log="/var/log/clicd.log"
error_log="/var/log/clicd.err"
depend() {
need net
after lxc
}
EOF
chmod +x /etc/init.d/clicd
rc-update add clicd default
rc-service clicd restart
}
install_service() {
log "Installing CLICD service..."
if is_systemd; then
install_systemd_service
elif is_openrc; then
install_openrc_service
else
die "No supported service manager found. CLICD supports systemd or OpenRC."
fi
}
print_summary() {
echo ""
echo "====================================="
echo " Installation Complete"
echo "====================================="
echo " Web: http://YOUR_SERVER_IP:8999"
echo " Binary: /usr/local/bin/clicd"
if is_systemd; then
echo " Service: systemctl {start|stop|restart|status} clicd"
echo " Logs: journalctl -u clicd -f"
elif is_openrc; then
echo " Service: rc-service clicd {start|stop|restart|status}"
echo " Logs: tail -f /var/log/clicd.log /var/log/clicd.err"
fi
echo "====================================="
echo ""
echo "Initial credentials, if this was the first run:"
if is_systemd; then
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
else
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
fi
echo ""
echo "If no password is shown, this server already had /root/.clicd/config.json."
echo "The existing admin password cannot be recovered from the bcrypt hash."
}
install_dependencies
configure_kernel_networking
setup_lxc_services
setup_subids
try_enable_project_quota
download_release_if_needed
install_binary
install_service
sleep 2 sleep 2
print_summary
echo ""
echo "====================================="
echo " Installation Complete"
echo "====================================="
echo " Web: http://YOUR_SERVER_IP:8999"
echo " Service: systemctl {start|stop|restart|status} clicd"
echo " Logs: journalctl -u clicd -f"
echo "====================================="
echo ""
echo "Initial credentials, if this was the first run:"
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
echo ""
echo "If no password is shown, this server already had /root/.clicd/config.json."
echo "The existing admin password cannot be recovered from the bcrypt hash."