mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28f81a8f1c | |||
| 6dcd5bd06c | |||
| 8b402fcfa1 | |||
| 8dd01fe714 | |||
| a9784539ea | |||
| bb8a646de7 | |||
| a2da61e076 | |||
| 49a6981e28 |
@@ -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
|
||||||
|
|||||||
@@ -63,3 +63,4 @@ backend/tmp/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
linux.txt
|
linux.txt
|
||||||
|
push-release.ps1
|
||||||
|
|||||||
@@ -37,53 +37,28 @@ CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、C
|
|||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
推荐使用最新 Release 一键安装。在目标服务器上执行:
|
一键安装:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以下载 GitHub Actions 构建出的 Release 产物 `clicd-linux-amd64.tar.gz` 后手动安装:
|
一键卸载:
|
||||||
|
|
||||||
```bash
|
|
||||||
tar -xzf clicd-linux-amd64.tar.gz
|
|
||||||
cd clicd-linux-amd64
|
|
||||||
sudo ./install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
安装完成后访问:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://YOUR_SERVER_IP:8999
|
|
||||||
```
|
|
||||||
|
|
||||||
首次启动时会自动初始化管理员账号:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Username: admin
|
|
||||||
Password: 随机 16 位密码
|
|
||||||
```
|
|
||||||
|
|
||||||
安装脚本会尝试从 systemd 日志中输出初始账号密码。如果机器上已经存在 `/root/.clicd/config.json`,则不会重新生成密码。
|
|
||||||
|
|
||||||
查看初始密码日志:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:"
|
|
||||||
```
|
|
||||||
|
|
||||||
卸载 CLICD:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
默认只删除 CLICD 服务和 `/usr/local/bin/clicd`,保留 `/root/.clicd` 配置数据和 `/var/lib/lxc` 容器。需要同时删除配置数据时:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall --purge-data
|
|
||||||
```
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,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 +189,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,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 +107,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 +127,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 +320,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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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{}{} }()
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -47,29 +69,24 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Check if sub-user already exists for this container
|
// Check if sub-user already exists for this container
|
||||||
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 := generateRandomStr(16)
|
||||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
||||||
su.PassHash = string(hash)
|
su.PassHash = string(hash)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if su.Password == "" {
|
su.Password = ""
|
||||||
su.Password = generateRandomStr(16)
|
su.Token = ""
|
||||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
||||||
su.PassHash = string(hash)
|
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||||
}
|
|
||||||
}
|
|
||||||
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: "Sub-user password rotated",
|
||||||
Data: *su,
|
Data: newSubUserResponse(*su, password),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,16 +101,12 @@ 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,
|
|
||||||
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 +115,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
|
||||||
@@ -126,7 +139,7 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
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
|
// Generate fresh token
|
||||||
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
|
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
||||||
if len(containerUUIDs) == 0 {
|
if len(containerUUIDs) == 0 {
|
||||||
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
|
||||||
@@ -173,7 +186,7 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
|
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
||||||
if len(containerUUIDs) == 0 {
|
if len(containerUUIDs) == 0 {
|
||||||
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
|
||||||
@@ -224,18 +237,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 +360,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 +390,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 +405,38 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func activeSubUserContainerUUIDs(su *config.SubUser) []string {
|
||||||
|
uuids := make([]string, 0, len(su.ContainerUUIDs))
|
||||||
|
for _, uuid := range su.ContainerUUIDs {
|
||||||
|
if c := config.FindContainerByUUID(uuid); c != nil {
|
||||||
|
uuids = appendUniqueString(uuids, c.UUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(uuids) > 0 {
|
||||||
|
return uuids
|
||||||
|
}
|
||||||
|
return subUserContainerUUIDs(su.ContainerNames)
|
||||||
|
}
|
||||||
|
|
||||||
func subUserContainerUUIDs(containerNames []string) []string {
|
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, "/") {
|
||||||
|
|||||||
@@ -442,6 +442,10 @@ 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"})
|
||||||
@@ -504,6 +508,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
|
||||||
|
|||||||
+509
-130
@@ -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()
|
||||||
@@ -20,12 +26,12 @@ func Run() {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
if _, err := config.InitConfig(); err != nil {
|
if _, err := config.InitConfig(); err != nil {
|
||||||
fmt.Printf("Failed to reload config: %v\n", err)
|
fmt.Printf("重新加载配置失败: %v\n", err)
|
||||||
waitEnter(reader)
|
waitEnter(reader)
|
||||||
}
|
}
|
||||||
clearScreen()
|
clearScreen()
|
||||||
printMenu()
|
printMenu()
|
||||||
fmt.Print("\nSelect action [1-11,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)
|
||||||
|
|
||||||
@@ -71,6 +77,10 @@ func Run() {
|
|||||||
cliImportExistingContainers()
|
cliImportExistingContainers()
|
||||||
waitEnter(reader)
|
waitEnter(reader)
|
||||||
case "11":
|
case "11":
|
||||||
|
clearScreen()
|
||||||
|
cliUpgradeSystem(reader)
|
||||||
|
waitEnter(reader)
|
||||||
|
case "12":
|
||||||
clearScreen()
|
clearScreen()
|
||||||
cliUninstall(reader)
|
cliUninstall(reader)
|
||||||
return
|
return
|
||||||
@@ -79,60 +89,62 @@ func Run() {
|
|||||||
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(" 10. Import existing ct-* containers")
|
fmt.Println(" 10. 导入现有 LXC 容器")
|
||||||
fmt.Println(" 11. Uninstall CLICD")
|
fmt.Println(" 11. 检查并升级 CLICD")
|
||||||
fmt.Println(" 0. System info")
|
fmt.Println(" 12. 卸载 CLICD")
|
||||||
fmt.Println(" q. Quit")
|
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 := "-"
|
||||||
@@ -145,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,22 +181,22 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -192,126 +204,441 @@ func cliCreateContainer(reader *bufio.Reader) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()
|
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()
|
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.")
|
fmt.Println("管理员密码已重置。")
|
||||||
restartWebPanelForConfigChange()
|
restartWebPanelForConfigChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
func cliToggleWebPanel() {
|
func cliToggleWebPanel() {
|
||||||
if isWebPanelRunning() {
|
if isWebPanelRunning() {
|
||||||
if err := stopService("clicd"); err != nil {
|
if err := stopService("clicd"); err != nil {
|
||||||
fmt.Printf("Failed to stop web panel: %v\n", err)
|
fmt.Printf("停止 Web 面板失败: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println("Web panel stopped. LXC containers are not affected.")
|
fmt.Println("Web 面板已停止,LXC 容器不会受影响。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := startService("clicd"); err != nil {
|
if err := startService("clicd"); err != nil {
|
||||||
fmt.Printf("Failed to start web panel: %v\n", err)
|
fmt.Printf("启动 Web 面板失败: %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 {
|
||||||
@@ -330,21 +657,21 @@ func isWebPanelRunning() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cliImportExistingContainers() {
|
func cliImportExistingContainers() {
|
||||||
fmt.Println("\n--- Import existing CLICD containers ---")
|
fmt.Println("\n--- 导入现有 LXC 容器 ---")
|
||||||
fmt.Println("This imports LXC containers named ct-{id} from /var/lib/lxc into CLICD config.")
|
fmt.Println("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
|
||||||
fmt.Println("Containers with names like ubuntu or alpine are skipped because CLICD requires ct-{id}.")
|
fmt.Println("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
|
||||||
|
|
||||||
imported, err := manager.ImportExistingClicdContainers()
|
imported, err := manager.ImportExistingClicdContainers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Import failed: %v\n", err)
|
fmt.Printf("导入失败: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(imported) == 0 {
|
if len(imported) == 0 {
|
||||||
fmt.Println("No new ct-* containers found to import.")
|
fmt.Println("没有发现新的 ct-* 容器。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Imported %d container(s):\n", len(imported))
|
fmt.Printf("已导入 %d 个容器:\n", len(imported))
|
||||||
for _, c := range imported {
|
for _, c := range imported {
|
||||||
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
|
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
|
||||||
}
|
}
|
||||||
@@ -352,62 +679,113 @@ func cliImportExistingContainers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cliUninstall(reader *bufio.Reader) {
|
func cliUninstall(reader *bufio.Reader) {
|
||||||
fmt.Println("\n--- Uninstall CLICD ---")
|
fmt.Println("\n--- 卸载 CLICD ---")
|
||||||
fmt.Println("This removes the CLICD service and /usr/local/bin/clicd.")
|
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
|
||||||
fmt.Println("LXC containers and /root/.clicd are kept unless you explicitly choose to delete them.")
|
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc 下全部 LXC 容器,以及 /var/cache/lxc 镜像缓存。")
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
fmt.Println("Uninstall must be run as root.")
|
fmt.Println("卸载需要 root 权限。")
|
||||||
fmt.Println("Run: sudo clicd cli --no-web")
|
fmt.Println("请运行: sudo clicd cli --no-web")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
confirm := promptString(reader, "Type uninstall to continue", "no")
|
confirm := promptString(reader, "输入 uninstall 继续卸载", "no")
|
||||||
if strings.ToLower(confirm) != "uninstall" {
|
if strings.ToLower(confirm) != "uninstall" {
|
||||||
fmt.Println("Canceled")
|
fmt.Println("已取消")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
removeData := strings.ToLower(promptString(reader, "Delete /root/.clicd config/data? Type delete-data", "no")) == "delete-data"
|
destroyAllLXCContainers()
|
||||||
removeContainers := strings.ToLower(promptString(reader, "Destroy CLICD-managed LXC containers? Type delete-containers", "no")) == "delete-containers"
|
|
||||||
|
|
||||||
if removeContainers {
|
|
||||||
destroyManagedContainers()
|
|
||||||
}
|
|
||||||
|
|
||||||
stopAndRemoveService()
|
stopAndRemoveService()
|
||||||
removePath("/usr/local/bin/clicd")
|
removePath("/usr/local/bin/clicd")
|
||||||
removePath("/etc/sysctl.d/99-clicd.conf")
|
removePath("/etc/sysctl.d/99-clicd.conf")
|
||||||
removePath("/var/log/clicd.log")
|
removePath("/var/log/clicd.log")
|
||||||
removePath("/var/log/clicd.err")
|
removePath("/var/log/clicd.err")
|
||||||
|
removePath("/root/.clicd")
|
||||||
if removeData {
|
removePath("/var/lib/lxc")
|
||||||
removePath("/root/.clicd")
|
removePath("/var/cache/lxc")
|
||||||
}
|
|
||||||
|
|
||||||
reloadSysctl()
|
reloadSysctl()
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("CLICD has been uninstalled.")
|
fmt.Println("CLICD 已卸载。")
|
||||||
if !removeData {
|
fmt.Println("服务、二进制、配置、容器和 LXC 镜像缓存均已删除。")
|
||||||
fmt.Println("Kept data: /root/.clicd")
|
|
||||||
}
|
|
||||||
if !removeContainers {
|
|
||||||
fmt.Println("Kept LXC containers under /var/lib/lxc")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func destroyManagedContainers() {
|
func destroyAllLXCContainers() {
|
||||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
entries, err := os.ReadDir("/var/lib/lxc")
|
||||||
if len(containers) == 0 {
|
if err != nil {
|
||||||
fmt.Println("No CLICD-managed containers found in config.")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range containers {
|
for _, entry := range entries {
|
||||||
fmt.Printf("Destroying container %s...\n", c.Name)
|
if !entry.IsDir() {
|
||||||
if err := manager.DestroyContainer(c.ID); err != nil {
|
continue
|
||||||
fmt.Printf("Failed to destroy %s: %v\n", c.Name, err)
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,10 +836,10 @@ func runQuiet(name string, args ...string) {
|
|||||||
|
|
||||||
func restartWebPanelForConfigChange() {
|
func restartWebPanelForConfigChange() {
|
||||||
if err := restartService("clicd"); err != nil {
|
if err := restartService("clicd"); err != nil {
|
||||||
fmt.Printf("Web panel reload skipped: %v\n", err)
|
fmt.Printf("Web 面板重载跳过: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println("Web panel reloaded to pick up config changes.")
|
fmt.Println("Web 面板已重载并应用配置变更。")
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopService(name string) error {
|
func stopService(name string) error {
|
||||||
@@ -497,7 +875,7 @@ func restartService(name string) error {
|
|||||||
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)
|
||||||
@@ -508,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, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,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')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,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)
|
||||||
|
|||||||
@@ -56,47 +56,60 @@ type AuditLog struct {
|
|||||||
|
|
||||||
// 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,14 +139,27 @@ 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:"-"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
type ClicdConfig struct {
|
type ClicdConfig struct {
|
||||||
AdminUser string `json:"admin_user"`
|
AdminUser string `json:"admin_user"`
|
||||||
@@ -153,11 +179,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 +274,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 +331,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 +362,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 +412,76 @@ 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.Password != "" {
|
||||||
|
su.Password = ""
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if su.Token != "" {
|
||||||
|
su.Token = ""
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if len(su.ContainerUUIDs) == 0 && len(su.ContainerNames) > 0 {
|
||||||
|
for _, name := range su.ContainerNames {
|
||||||
|
if c := FindContainerByName(name); c != nil && c.UUID != "" {
|
||||||
|
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(su.ContainerUUIDs) > 0 {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueString(values []string, value string) []string {
|
||||||
|
for _, existing := range values {
|
||||||
|
if existing == value {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeSnapshotLimit(limit int) int {
|
||||||
|
if limit <= 0 {
|
||||||
|
return DefaultSnapshotLimit
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func ContainerSnapshotLimit(c *Container) int {
|
||||||
|
if c == nil {
|
||||||
|
return DefaultSnapshotLimit
|
||||||
|
}
|
||||||
|
return NormalizeSnapshotLimit(c.SnapshotLimit)
|
||||||
|
}
|
||||||
|
|
||||||
func removeLegacyVNCMappings() bool {
|
func removeLegacyVNCMappings() bool {
|
||||||
changed := false
|
changed := false
|
||||||
for i := range AppConfig.Containers {
|
for i := range AppConfig.Containers {
|
||||||
@@ -403,7 +534,8 @@ 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)
|
||||||
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 +544,58 @@ func RemoveContainer(id int) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeSubUserContainerAccess(containerName string) {
|
func AddSnapshot(snapshot Snapshot) {
|
||||||
if containerName == "" || len(AppConfig.SubUsers) == 0 {
|
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 +606,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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-11
@@ -216,6 +216,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 +227,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 +358,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 +367,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 +1424,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 +1474,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
|
||||||
|
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
|
||||||
|
os.RemoveAll(snapshotDir)
|
||||||
|
}
|
||||||
|
|
||||||
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,9 +1935,9 @@ func (m *Manager) ListContainers() ([]config.Container, error) {
|
|||||||
return containers, nil
|
return containers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportExistingClicdContainers imports LXC containers named ct-{id} into the
|
// ImportExistingClicdContainers imports existing LXC containers into the CLICD
|
||||||
// CLICD config. Containers with arbitrary LXC names cannot be imported because
|
// config. Native CLICD containers keep ct-{id}; arbitrary LXC names are stored
|
||||||
// CLICD derives the runtime LXC name from the numeric container ID.
|
// in Container.LXCName so Web and CLI can manage the same imported container.
|
||||||
func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
||||||
entries, err := os.ReadDir(m.LxcPath)
|
entries, err := os.ReadDir(m.LxcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1926,10 +1946,12 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
|||||||
|
|
||||||
existingIDs := make(map[int]bool)
|
existingIDs := make(map[int]bool)
|
||||||
existingNames := make(map[string]bool)
|
existingNames := make(map[string]bool)
|
||||||
|
existingLXCNames := make(map[string]bool)
|
||||||
maxID := config.AppConfig.NextContainerID - 1
|
maxID := config.AppConfig.NextContainerID - 1
|
||||||
for _, c := range config.AppConfig.Containers {
|
for _, c := range config.AppConfig.Containers {
|
||||||
existingIDs[c.ID] = true
|
existingIDs[c.ID] = true
|
||||||
existingNames[c.Name] = true
|
existingNames[c.Name] = true
|
||||||
|
existingLXCNames[c.LxcName()] = true
|
||||||
if c.ID > maxID {
|
if c.ID > maxID {
|
||||||
maxID = c.ID
|
maxID = c.ID
|
||||||
}
|
}
|
||||||
@@ -1941,22 +1963,30 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
|||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matches := re.FindStringSubmatch(entry.Name())
|
lxcName := entry.Name()
|
||||||
if len(matches) != 2 {
|
if existingLXCNames[lxcName] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := strconv.Atoi(matches[1])
|
id := 0
|
||||||
if err != nil || id <= 0 || existingIDs[id] {
|
if matches := re.FindStringSubmatch(lxcName); len(matches) == 2 {
|
||||||
continue
|
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 := entry.Name()
|
name := lxcName
|
||||||
if existingNames[name] {
|
if existingNames[name] {
|
||||||
name = fmt.Sprintf("imported-%d", id)
|
name = fmt.Sprintf("imported-%d", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
status, err := m.GetContainerStatus(entry.Name())
|
status, err := m.GetContainerStatus(lxcName)
|
||||||
if err != nil || status == "" {
|
if err != nil || status == "" {
|
||||||
status = "unknown"
|
status = "unknown"
|
||||||
}
|
}
|
||||||
@@ -1965,6 +1995,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
|||||||
ID: id,
|
ID: id,
|
||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
Name: name,
|
Name: name,
|
||||||
|
LXCName: lxcName,
|
||||||
Template: "imported",
|
Template: "imported",
|
||||||
VCPU: 1,
|
VCPU: 1,
|
||||||
RAMMB: 512,
|
RAMMB: 512,
|
||||||
@@ -1975,10 +2006,11 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
|||||||
Status: status,
|
Status: status,
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
PortMappingLimit: 2,
|
PortMappingLimit: 2,
|
||||||
|
SnapshotLimit: config.DefaultSnapshotLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == "running" {
|
if status == "running" {
|
||||||
if ip, err := m.GetContainerIP(entry.Name()); err == nil {
|
if ip, err := m.GetContainerIP(lxcName); err == nil {
|
||||||
c.IP = ip
|
c.IP = ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1987,6 +2019,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
|||||||
imported = append(imported, c)
|
imported = append(imported, c)
|
||||||
existingIDs[id] = true
|
existingIDs[id] = true
|
||||||
existingNames[name] = true
|
existingNames[name] = true
|
||||||
|
existingLXCNames[lxcName] = true
|
||||||
if id > maxID {
|
if id > maxID {
|
||||||
maxID = id
|
maxID = id
|
||||||
}
|
}
|
||||||
@@ -2076,6 +2109,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)
|
||||||
}
|
}
|
||||||
@@ -2091,6 +2129,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()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package lxc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var snapshotMu sync.Mutex
|
||||||
|
|
||||||
|
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||||
|
snapshotMu.Lock()
|
||||||
|
defer snapshotMu.Unlock()
|
||||||
|
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c == nil {
|
||||||
|
return config.Snapshot{}, fmt.Errorf("container not found: %d", id)
|
||||||
|
}
|
||||||
|
if scheduled && rotateLimit > 0 {
|
||||||
|
for {
|
||||||
|
existing := config.ContainerSnapshots(id)
|
||||||
|
if len(existing) < rotateLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sortSnapshotsOldestFirst(existing)
|
||||||
|
if err := m.deleteSnapshotLocked(existing[0]); err != nil {
|
||||||
|
return config.Snapshot{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lxcName := c.LxcName()
|
||||||
|
containerDir := filepath.Join(m.LxcPath, lxcName)
|
||||||
|
if _, err := os.Stat(containerDir); err != nil {
|
||||||
|
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||||
|
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, snapshotID)
|
||||||
|
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
||||||
|
return config.Snapshot{}, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
||||||
|
return config.Snapshot{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wasRunning, err := m.prepareContainerForColdCopy(id, lxcName, containerDir)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(snapshotDir)
|
||||||
|
return config.Snapshot{}, err
|
||||||
|
}
|
||||||
|
if wasRunning {
|
||||||
|
defer func() {
|
||||||
|
if err := m.StartContainer(id); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to restart %s after snapshot: %v\n", lxcName, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyTree(containerDir, snapshotDir); err != nil {
|
||||||
|
os.RemoveAll(snapshotDir)
|
||||||
|
return config.Snapshot{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := config.Snapshot{
|
||||||
|
ID: snapshotID,
|
||||||
|
ContainerID: c.ID,
|
||||||
|
ContainerName: c.Name,
|
||||||
|
LXCName: lxcName,
|
||||||
|
CreatedAt: now.Format("2006-01-02 15:04:05"),
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
Scheduled: scheduled,
|
||||||
|
Path: snapshotDir,
|
||||||
|
SizeBytes: dirSizeBytes(snapshotDir),
|
||||||
|
}
|
||||||
|
config.AddSnapshot(snapshot)
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) DeleteSnapshot(id string) error {
|
||||||
|
snapshotMu.Lock()
|
||||||
|
defer snapshotMu.Unlock()
|
||||||
|
|
||||||
|
snapshot := config.FindSnapshot(id)
|
||||||
|
if snapshot == nil {
|
||||||
|
return fmt.Errorf("snapshot not found: %s", id)
|
||||||
|
}
|
||||||
|
return m.deleteSnapshotLocked(*snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||||
|
if snapshot.Path != "" {
|
||||||
|
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete snapshot files: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.RemoveSnapshot(snapshot.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) RestoreSnapshot(id string) error {
|
||||||
|
snapshotMu.Lock()
|
||||||
|
defer snapshotMu.Unlock()
|
||||||
|
|
||||||
|
snapshot := config.FindSnapshot(id)
|
||||||
|
if snapshot == nil {
|
||||||
|
return fmt.Errorf("snapshot not found: %s", id)
|
||||||
|
}
|
||||||
|
if snapshot.Path == "" {
|
||||||
|
return fmt.Errorf("snapshot path is empty")
|
||||||
|
}
|
||||||
|
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||||
|
return fmt.Errorf("snapshot files not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := config.FindContainer(snapshot.ContainerID)
|
||||||
|
if c == nil {
|
||||||
|
return fmt.Errorf("container not found: %d", snapshot.ContainerID)
|
||||||
|
}
|
||||||
|
lxcName := c.LxcName()
|
||||||
|
containerDir := filepath.Join(m.LxcPath, lxcName)
|
||||||
|
if err := safePathUnder(containerDir, m.LxcPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wasRunning, err := m.prepareContainerForColdCopy(c.ID, lxcName, containerDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
backupDir := filepath.Join(m.LxcPath, fmt.Sprintf(".%s-restore-backup-%d", lxcName, time.Now().UnixNano()))
|
||||||
|
if err := safePathUnder(backupDir, m.LxcPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(containerDir, backupDir); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to move current container aside: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyTree(snapshot.Path, containerDir); err != nil {
|
||||||
|
os.RemoveAll(containerDir)
|
||||||
|
_ = os.Rename(backupDir, containerDir)
|
||||||
|
return fmt.Errorf("failed to restore snapshot: %v", err)
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(backupDir)
|
||||||
|
|
||||||
|
config.UpdateContainerStatus(c.ID, "stopped")
|
||||||
|
if wasRunning {
|
||||||
|
return m.StartContainer(c.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetSnapshotSchedule(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("container not found: %d", id)
|
||||||
|
}
|
||||||
|
if intervalHours < 24 {
|
||||||
|
return nil, fmt.Errorf("snapshot schedule interval cannot be less than 24 hours")
|
||||||
|
}
|
||||||
|
if _, err := parseScheduleClock(scheduleTime); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.SnapshotScheduleEnabled = enabled
|
||||||
|
c.SnapshotScheduleIntervalHours = intervalHours
|
||||||
|
c.SnapshotScheduleTime = scheduleTime
|
||||||
|
c.SnapshotScheduleCreatedBy = createdBy
|
||||||
|
if enabled {
|
||||||
|
c.SnapshotScheduleNextRun = nextSnapshotRun(time.Now(), intervalHours, scheduleTime).Format(time.RFC3339)
|
||||||
|
} else {
|
||||||
|
c.SnapshotScheduleNextRun = ""
|
||||||
|
}
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) StartSnapshotScheduler() {
|
||||||
|
go func() {
|
||||||
|
m.runDueSnapshotSchedules()
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
m.runDueSnapshotSchedules()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runDueSnapshotSchedules() {
|
||||||
|
now := time.Now()
|
||||||
|
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||||
|
for _, c := range containers {
|
||||||
|
if !c.SnapshotScheduleEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
|
||||||
|
if err != nil || c.SnapshotScheduleNextRun == "" {
|
||||||
|
nextRun = now
|
||||||
|
}
|
||||||
|
if now.Before(nextRun) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
createdBy := c.SnapshotScheduleCreatedBy
|
||||||
|
if createdBy == "" {
|
||||||
|
createdBy = "admin"
|
||||||
|
}
|
||||||
|
rotateLimit := 0
|
||||||
|
if strings.HasPrefix(createdBy, "user:") {
|
||||||
|
rotateLimit = config.ContainerSnapshotLimit(&c)
|
||||||
|
}
|
||||||
|
if _, err := m.CreateSnapshot(c.ID, createdBy, true, rotateLimit); err != nil {
|
||||||
|
fmt.Printf("Warning: scheduled snapshot failed for %s: %v\n", c.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current := config.FindContainer(c.ID); current != nil {
|
||||||
|
interval := current.SnapshotScheduleIntervalHours
|
||||||
|
if interval < 24 {
|
||||||
|
interval = 24
|
||||||
|
}
|
||||||
|
next := nextRun.Add(time.Duration(interval) * time.Hour)
|
||||||
|
for !next.After(now) {
|
||||||
|
next = next.Add(time.Duration(interval) * time.Hour)
|
||||||
|
}
|
||||||
|
current.SnapshotScheduleLastRun = now.Format(time.RFC3339)
|
||||||
|
current.SnapshotScheduleNextRun = next.Format(time.RFC3339)
|
||||||
|
config.SaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScheduleClock(value string) (time.Duration, error) {
|
||||||
|
parts := strings.Split(value, ":")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, fmt.Errorf("snapshot schedule time must be HH:MM")
|
||||||
|
}
|
||||||
|
hour, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil || hour < 0 || hour > 23 {
|
||||||
|
return 0, fmt.Errorf("snapshot schedule hour must be 00-23")
|
||||||
|
}
|
||||||
|
minute, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil || minute < 0 || minute > 59 {
|
||||||
|
return 0, fmt.Errorf("snapshot schedule minute must be 00-59")
|
||||||
|
}
|
||||||
|
return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) time.Time {
|
||||||
|
clock, err := parseScheduleClock(scheduleTime)
|
||||||
|
if err != nil {
|
||||||
|
clock = 3 * time.Hour
|
||||||
|
}
|
||||||
|
midnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
|
||||||
|
next := midnight.Add(clock)
|
||||||
|
interval := time.Duration(intervalHours) * time.Hour
|
||||||
|
for !next.After(from) {
|
||||||
|
next = next.Add(interval)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerDir string) (bool, error) {
|
||||||
|
status, _ := m.GetContainerStatus(lxcName)
|
||||||
|
wasRunning := status == "running"
|
||||||
|
if wasRunning {
|
||||||
|
if err := m.StopContainer(id); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
} else if c := config.FindContainer(id); c != nil {
|
||||||
|
m.CleanPortMappings(id)
|
||||||
|
m.cleanupBandwidthLimit(c.LxcName())
|
||||||
|
}
|
||||||
|
rootfs := filepath.Join(containerDir, "rootfs")
|
||||||
|
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||||
|
m.detachContainerMounts(containerDir)
|
||||||
|
m.detachContainerLoopDevices(containerDir)
|
||||||
|
return wasRunning, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotBaseDir() string {
|
||||||
|
return filepath.Join(config.AppConfig.DataDir, "snapshots")
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyTree(src string, dst string) error {
|
||||||
|
if err := os.MkdirAll(dst, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output, err := exec.Command("cp", "-a", "--sparse=always", "--reflink=auto", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
output, err = exec.Command("cp", "-a", "--sparse=always", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cp failed: %v, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirSizeBytes(path string) int64 {
|
||||||
|
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parts := strings.Fields(string(out))
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
fmt.Sscanf(parts[0], "%d", &size)
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
func safePathUnder(path string, base string) error {
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
absBase, err := filepath.Abs(base)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if absPath == absBase || strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("refusing unsafe path: %s", absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortSnapshotsOldestFirst(snapshots []config.Snapshot) {
|
||||||
|
sort.SliceStable(snapshots, func(i, j int) bool {
|
||||||
|
ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt)
|
||||||
|
tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt)
|
||||||
|
return ti.Before(tj)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,12 +20,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 +41,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 +87,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)))
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
var (
|
||||||
|
Version = "1.0.6"
|
||||||
|
Repo = "MengMengCode/CLICD"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Current() string {
|
||||||
|
if Version == "" {
|
||||||
|
return "dev"
|
||||||
|
}
|
||||||
|
return Version
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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 Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -57,6 +59,8 @@ 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="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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: '',
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
const containers: CreateContainerRequest[] = []
|
const containers: CreateContainerRequest[] = []
|
||||||
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}-${i + 1}` : boundedForm.name
|
||||||
containers.push({ ...boundedForm, name, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), extra_ports: [] })
|
containers.push({
|
||||||
|
...boundedForm,
|
||||||
|
name,
|
||||||
|
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
|
||||||
|
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||||
|
extra_ports: [],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -248,6 +255,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 +341,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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Code2,
|
Code2,
|
||||||
|
Camera,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Package,
|
Package,
|
||||||
|
Route,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
@@ -31,6 +33,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')
|
||||||
@@ -136,6 +140,30 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
{!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'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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 ${
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -125,6 +135,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 +161,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 +232,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,6 +516,108 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateSnapshot = async () => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||||
|
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (container?.status === 'running') {
|
||||||
|
const confirmed = await dialog.confirm(
|
||||||
|
'拍摄快照',
|
||||||
|
`拍摄快照需要先关机,完成后会自动重启容器 ${container.name}。是否继续?`
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
setSnapshotBusy('create')
|
||||||
|
try {
|
||||||
|
await createContainerSnapshot(containerIdentifier)
|
||||||
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('创建快照失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSnapshotSchedule = () => {
|
||||||
|
setSnapshotScheduleDraft({
|
||||||
|
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||||
|
time: snapshotSchedule?.time || '03:00',
|
||||||
|
})
|
||||||
|
setShowSnapshotSchedule(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSnapshotSchedule = async (enabled: boolean) => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
const intervalHours = snapshotScheduleDraft.intervalHours
|
||||||
|
const scheduleTime = snapshotScheduleDraft.time || '03:00'
|
||||||
|
if (enabled && intervalHours < 24) {
|
||||||
|
await dialog.alert('参数错误', '自动快照周期最低是 1 天一次。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSnapshotBusy('schedule')
|
||||||
|
try {
|
||||||
|
await updateSnapshotSchedule(containerIdentifier, enabled, intervalHours, scheduleTime)
|
||||||
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
|
setShowSnapshotSchedule(false)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('定时快照失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSnapshotQuota = async () => {
|
||||||
|
if (!containerIdentifier || isSubUser) return
|
||||||
|
const nextQuota = Math.max(1, Math.round(snapshotQuotaDraft || 1))
|
||||||
|
setSnapshotBusy('quota')
|
||||||
|
try {
|
||||||
|
await updateSnapshotQuota(containerIdentifier, nextQuota)
|
||||||
|
setSnapshotQuota(nextQuota)
|
||||||
|
setSnapshotQuotaDraft(nextQuota)
|
||||||
|
setEditingSnapshotQuota(false)
|
||||||
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('保存快照配额失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
|
||||||
|
setSnapshotBusy(snapshot.id)
|
||||||
|
try {
|
||||||
|
await deleteContainerSnapshot(containerIdentifier, snapshot.id)
|
||||||
|
await fetchSnapshots()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('删除快照失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
|
||||||
|
setSnapshotBusy(snapshot.id)
|
||||||
|
try {
|
||||||
|
await restoreContainerSnapshot(containerIdentifier, snapshot.id)
|
||||||
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('恢复快照失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyText = async (text: string) => {
|
const copyText = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
await copyText(text)
|
await copyText(text)
|
||||||
@@ -641,6 +781,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 +990,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 && (
|
||||||
@@ -1229,6 +1538,65 @@ function PlainRow({ label, value, mono = false, copyValue, onCopy, children }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SnapshotTable({ snapshots, busy, onRestore, onDelete }: {
|
||||||
|
snapshots: Snapshot[]
|
||||||
|
busy: string
|
||||||
|
onRestore: (snapshot: Snapshot) => void
|
||||||
|
onDelete: (snapshot: Snapshot) => void
|
||||||
|
}) {
|
||||||
|
if (snapshots.length === 0) {
|
||||||
|
return <p className="rounded-lg border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-400">暂无快照</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||||
|
<table className="w-full min-w-[760px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<TableHead>快照时间</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>创建者</TableHead>
|
||||||
|
<TableHead>大小</TableHead>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{snapshots.map((snapshot) => (
|
||||||
|
<tr key={snapshot.id}>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-gray-800">{snapshot.created_at}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{snapshot.scheduled ? '定时' : '手动'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-gray-600">{snapshot.created_by || '-'}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onRestore(snapshot)}
|
||||||
|
disabled={!!busy}
|
||||||
|
className="rounded border border-gray-300 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === snapshot.id ? '处理中...' : '恢复'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(snapshot)}
|
||||||
|
disabled={!!busy}
|
||||||
|
className="rounded border border-red-200 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false, isSubUser = false }: { mappings: PortMapping[]; publicHost: string; onEdit: (pm: PortMapping, index: number) => void; onDelete: (index: number) => void; compact?: boolean; isSubUser?: boolean }) {
|
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 +1757,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`
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Network, RefreshCw, Route, Server } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { getRoutingInfo, RoutingInfo } from '../services/api'
|
||||||
|
|
||||||
|
export default function Routing() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [routing, setRouting] = useState<RoutingInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [nat4Page, setNat4Page] = useState(1)
|
||||||
|
const [ipv6Page, setIPv6Page] = useState(1)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getRoutingInfo()
|
||||||
|
setRouting(res.data.data || null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nat4Mappings = routing?.nat4_mappings || []
|
||||||
|
const ipv6Assignments = routing?.ipv6_assignments || []
|
||||||
|
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
|
||||||
|
const pageSize = 10
|
||||||
|
const nat4TotalPages = Math.max(1, Math.ceil(nat4Mappings.length / pageSize))
|
||||||
|
const ipv6TotalPages = Math.max(1, Math.ceil(ipv6Assignments.length / pageSize))
|
||||||
|
const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
|
||||||
|
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
|
||||||
|
const pagedNat4Mappings = nat4Mappings.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
|
||||||
|
const pagedIPv6Assignments = ipv6Assignments.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-black">路由管理</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">宿主机分配给 LXC 的 NAT4 端口和 IPv6 地址</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRefreshing(true); fetchData() }}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<CapacityCard
|
||||||
|
title="NAT4 端口"
|
||||||
|
icon={<Route className="h-5 w-5 text-gray-600" />}
|
||||||
|
remaining={routing?.nat4.remaining || '0'}
|
||||||
|
total={routing?.nat4.total || '0'}
|
||||||
|
used={routing?.nat4.used || 0}
|
||||||
|
label="剩余端口 / 端口总数"
|
||||||
|
/>
|
||||||
|
<CapacityCard
|
||||||
|
title="IPv6 地址"
|
||||||
|
icon={<Network className="h-5 w-5 text-gray-600" />}
|
||||||
|
remaining={formatCapacity(routing?.ipv6.remaining || '0')}
|
||||||
|
total={formatCapacity(routing?.ipv6.total || '0')}
|
||||||
|
used={routing?.ipv6.used || 0}
|
||||||
|
label={`剩余地址 / 地址总数 · ${ipv6Prefix}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
<div className="border-b border-gray-200 px-4 py-3">
|
||||||
|
<div className="text-sm font-medium text-black">NAT4 端口分配</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">共 {nat4Mappings.length} 条映射</div>
|
||||||
|
</div>
|
||||||
|
{nat4Mappings.length === 0 ? (
|
||||||
|
<EmptyState icon={<Route className="h-7 w-7 text-gray-400" />} text="暂无 NAT4 端口映射" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[900px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">容器</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">容器 IPv4</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">宿主机端口</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">容器端口</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">协议</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">说明</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{pagedNat4Mappings.map((mapping, index) => (
|
||||||
|
<tr key={`${mapping.container_id}-${mapping.host_port}-${mapping.protocol}-${index}`} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/container/${mapping.container_id}`)}
|
||||||
|
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
||||||
|
>
|
||||||
|
<Server className="h-4 w-4 text-gray-400" />
|
||||||
|
{mapping.container_name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.lxc_name}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.ip || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.host_port}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.container_port}</td>
|
||||||
|
<td className="px-4 py-3 uppercase text-gray-600">{mapping.protocol || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">{mapping.description || '-'}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={mapping.status} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
page={currentNat4Page}
|
||||||
|
totalPages={nat4TotalPages}
|
||||||
|
totalItems={nat4Mappings.length}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setNat4Page}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
<div className="border-b border-gray-200 px-4 py-3">
|
||||||
|
<div className="text-sm font-medium text-black">IPv6 地址分配</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">共 {ipv6Assignments.length} 个地址</div>
|
||||||
|
</div>
|
||||||
|
{ipv6Assignments.length === 0 ? (
|
||||||
|
<EmptyState icon={<Network className="h-7 w-7 text-gray-400" />} text="暂无 IPv6 地址分配" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[820px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">容器</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">IPv6 地址</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">前缀</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">出口网卡</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{pagedIPv6Assignments.map((item) => (
|
||||||
|
<tr key={`${item.container_id}-${item.address}`} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/container/${item.container_id}`)}
|
||||||
|
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
||||||
|
>
|
||||||
|
<Server className="h-4 w-4 text-gray-400" />
|
||||||
|
{item.container_name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.lxc_name}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{item.address}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">/{item.prefix_len || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.interface || '-'}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
page={currentIPv6Page}
|
||||||
|
totalPages={ipv6TotalPages}
|
||||||
|
totalItems={ipv6Assignments.length}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setIPv6Page}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ page, totalPages, totalItems, pageSize, onPageChange }: {
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
totalItems: number
|
||||||
|
pageSize: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
}) {
|
||||||
|
if (totalPages <= 1) return null
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize + 1
|
||||||
|
const end = Math.min(page * pageSize, totalItems)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
显示 {start}-{end},共 {totalItems} 条
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="min-w-16 text-center text-xs text-gray-500">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapacityCard({ title, icon, remaining, total, used, label }: {
|
||||||
|
title: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
remaining: string
|
||||||
|
total: string
|
||||||
|
used: number
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||||
|
<div className="mt-2 flex items-end gap-2">
|
||||||
|
<span className="text-2xl font-semibold text-black">{remaining}</span>
|
||||||
|
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-gray-100">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-gray-500">{label}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">已分配 {used}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">{text}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const running = status === 'running'
|
||||||
|
return (
|
||||||
|
<span className={`rounded px-2 py-1 text-xs ${running ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{running ? '运行中' : (status || '未知')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCapacity(value: string): string {
|
||||||
|
if (value === 'large') return '充足'
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Camera, RefreshCw, Server } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { getSnapshots, Snapshot } from '../services/api'
|
||||||
|
|
||||||
|
export default function Snapshots() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getSnapshots()
|
||||||
|
setSnapshots(res.data.data || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-black">快照管理</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">全局快照列表,共 {snapshots.length} 个</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRefreshing(true); fetchData() }}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
{snapshots.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<Camera className="h-7 w-7 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">暂无快照</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[820px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">容器</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">快照时间</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">类型</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">创建者</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">大小</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{snapshots.map((snapshot) => (
|
||||||
|
<tr key={snapshot.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/container/${snapshot.container_id}`)}
|
||||||
|
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
||||||
|
>
|
||||||
|
<Server className="h-4 w-4 text-gray-400" />
|
||||||
|
{snapshot.container_name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{snapshot.lxc_name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{snapshot.created_at}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{snapshot.scheduled ? '定时' : '手动'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return '-'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -275,6 +283,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 +401,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 +493,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
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-46
@@ -50,7 +50,7 @@ usage() {
|
|||||||
cat << EOF
|
cat << EOF
|
||||||
Usage:
|
Usage:
|
||||||
./install.sh
|
./install.sh
|
||||||
./install.sh uninstall [--purge-data] [--delete-containers]
|
./install.sh uninstall
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
CLICD_REPO=owner/repo
|
CLICD_REPO=owner/repo
|
||||||
@@ -71,30 +71,74 @@ remove_path() {
|
|||||||
log "Removed $path"
|
log "Removed $path"
|
||||||
}
|
}
|
||||||
|
|
||||||
uninstall_clicd() {
|
unmount_path_tree() {
|
||||||
purge_data=0
|
path="$1"
|
||||||
delete_containers=0
|
if [ ! -e "$path" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
shift || true
|
if has_cmd findmnt; then
|
||||||
while [ "$#" -gt 0 ]; do
|
findmnt -R -n -o TARGET "$path" 2>/dev/null | sort -r | while IFS= read -r mountpoint; do
|
||||||
case "$1" in
|
[ -n "$mountpoint" ] || continue
|
||||||
--purge-data)
|
umount -R -l "$mountpoint" >/dev/null 2>&1 || umount -l "$mountpoint" >/dev/null 2>&1 || true
|
||||||
purge_data=1
|
done
|
||||||
;;
|
fi
|
||||||
--delete-containers)
|
|
||||||
delete_containers=1
|
umount -R -l "$path/rootfs" >/dev/null 2>&1 || umount -l "$path/rootfs" >/dev/null 2>&1 || true
|
||||||
;;
|
umount -R -l "$path" >/dev/null 2>&1 || umount -l "$path" >/dev/null 2>&1 || true
|
||||||
-h|--help)
|
}
|
||||||
usage
|
|
||||||
exit 0
|
detach_container_loop_devices() {
|
||||||
;;
|
path="$1"
|
||||||
*)
|
if ! has_cmd losetup; then
|
||||||
die "Unknown uninstall option: $1"
|
return
|
||||||
;;
|
fi
|
||||||
esac
|
|
||||||
shift
|
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
|
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..."
|
log "Uninstalling CLICD..."
|
||||||
|
|
||||||
if has_cmd systemctl; then
|
if has_cmd systemctl; then
|
||||||
@@ -109,16 +153,11 @@ uninstall_clicd() {
|
|||||||
rc-update del clicd default >/dev/null 2>&1 || true
|
rc-update del clicd default >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$delete_containers" -eq 1 ]; then
|
log "Destroying LXC containers under /var/lib/lxc..."
|
||||||
log "Destroying CLICD-style LXC containers named ct-*..."
|
for container_dir in /var/lib/lxc/*; do
|
||||||
for container_dir in /var/lib/lxc/ct-*; do
|
[ -d "$container_dir" ] || continue
|
||||||
[ -d "$container_dir" ] || continue
|
remove_lxc_container_dir "$container_dir"
|
||||||
container_name="$(basename "$container_dir")"
|
done
|
||||||
lxc-stop -n "$container_name" -k >/dev/null 2>&1 || true
|
|
||||||
lxc-destroy -n "$container_name" -f >/dev/null 2>&1 || true
|
|
||||||
remove_path "$container_dir"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
remove_path /etc/systemd/system/clicd.service
|
remove_path /etc/systemd/system/clicd.service
|
||||||
remove_path /etc/init.d/clicd
|
remove_path /etc/init.d/clicd
|
||||||
@@ -126,10 +165,10 @@ uninstall_clicd() {
|
|||||||
remove_path /etc/sysctl.d/99-clicd.conf
|
remove_path /etc/sysctl.d/99-clicd.conf
|
||||||
remove_path /var/log/clicd.log
|
remove_path /var/log/clicd.log
|
||||||
remove_path /var/log/clicd.err
|
remove_path /var/log/clicd.err
|
||||||
|
remove_path /root/.clicd
|
||||||
if [ "$purge_data" -eq 1 ]; then
|
unmount_path_tree /var/lib/lxc
|
||||||
remove_path /root/.clicd
|
remove_path /var/lib/lxc
|
||||||
fi
|
remove_path /var/cache/lxc
|
||||||
|
|
||||||
if has_cmd systemctl; then
|
if has_cmd systemctl; then
|
||||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
@@ -143,14 +182,7 @@ uninstall_clicd() {
|
|||||||
echo "====================================="
|
echo "====================================="
|
||||||
echo " CLICD Uninstalled"
|
echo " CLICD Uninstalled"
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
if [ "$purge_data" -eq 0 ]; then
|
echo " Removed service, binary, config, containers, and LXC image cache."
|
||||||
echo " Kept data: /root/.clicd"
|
|
||||||
fi
|
|
||||||
if [ "$delete_containers" -eq 0 ]; then
|
|
||||||
echo " Kept LXC containers: /var/lib/lxc"
|
|
||||||
echo " To delete CLICD-style ct-* containers too:"
|
|
||||||
echo " ./install.sh uninstall --delete-containers"
|
|
||||||
fi
|
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +190,7 @@ case "$ACTION" in
|
|||||||
install|"")
|
install|"")
|
||||||
;;
|
;;
|
||||||
uninstall|remove)
|
uninstall|remove)
|
||||||
uninstall_clicd "$@"
|
uninstall_clicd
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
-h|--help|help)
|
-h|--help|help)
|
||||||
@@ -416,7 +448,17 @@ download_release_if_needed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_binary() {
|
install_binary() {
|
||||||
cp ./clicd /usr/local/bin/clicd
|
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
|
chmod +x /usr/local/bin/clicd
|
||||||
log "Installed binary: /usr/local/bin/clicd"
|
log "Installed binary: /usr/local/bin/clicd"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user