mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 007811ab41 | |||
| 95af3e44f2 | |||
| 2ad17fa520 | |||
| 08a1a057e7 | |||
| e9f657ab17 | |||
| 7e5da67de4 |
@@ -65,3 +65,4 @@ Thumbs.db
|
||||
linux.txt
|
||||
push-release.ps1
|
||||
deploy.ps1
|
||||
backend/clicd
|
||||
|
||||
@@ -53,6 +53,28 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
|
||||

|
||||

|
||||
|
||||
|
||||
## Disclaimer/免责声明
|
||||
|
||||
This open-source software does not distribute Windows system images, nor does it provide any means to bypass or circumvent Windows activation mechanisms.
|
||||
|
||||
All download links provided within the software point to resources officially supplied by Microsoft. Users of this software are responsible for obtaining the appropriate licenses from Microsoft before using any Windows operating system downloaded through these links. This project does not bypass activation requirements for installed systems, nor does it assume any responsibility for the consequences of users' actions when using this software.
|
||||
|
||||
This open-source software is intended solely for educational purposes, specifically for learning the principles of LXC and KVM. The copyright for the Windows logo and related icons belongs to Microsoft/Windows.
|
||||
|
||||
本开源软件不提供任何 Windows 操作系统镜像的分发服务,也不包含任何绕过、破解或免除 Windows 激活机制的功能。
|
||||
|
||||
软件内涉及的 Windows 系统下载链接均由微软官方提供。使用者在下载、安装和使用相关 Windows 系统时,应自行向微软或其授权渠道购买并获得相应的软件许可。本项目不会对安装后的 Windows 系统进行任何形式的激活绕过、破解或免激活处理。
|
||||
|
||||
对于使用者因使用本软件而产生的任何行为及其后果,包括但不限于软件许可、系统使用、数据丢失、法律责任或其他相关问题,本项目及其开发者不承担任何责任。
|
||||
|
||||
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
|
||||
|
||||
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
|
||||
## Thanks/鸣谢
|
||||
|
||||
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
|
||||
@@ -61,8 +83,4 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
|
||||
<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>
|
||||
|
||||
## 鸣谢
|
||||
|
||||
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
|
||||
</a>
|
||||
+23
-2
@@ -1,6 +1,8 @@
|
||||
module clicd
|
||||
|
||||
go 1.22.0
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
@@ -9,4 +11,23 @@ require (
|
||||
golang.org/x/term v0.28.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.29.0 // indirect
|
||||
require (
|
||||
golang.org/x/sys v0.31.0
|
||||
modernc.org/sqlite v1.29.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
modernc.org/strutil v1.2.1 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
+53
-2
@@ -1,10 +1,61 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
|
||||
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -177,6 +177,10 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if c.IsKVM() && c.Status == "running" {
|
||||
_, _ = kvmManager.RefreshVNCPort(c.ID)
|
||||
_, _ = kvmManager.RefreshNetwork(c.ID)
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"clicd/internal/lxc"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type HostInfo struct {
|
||||
@@ -135,8 +136,8 @@ func getMemoryInfo() MemoryInfo {
|
||||
}
|
||||
|
||||
func getDiskInfo() DiskInfo {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs("/", &stat); err != nil {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs("/", &stat); err != nil {
|
||||
// Try command-based fallback
|
||||
cmd := exec.Command("df", "-BG", "/")
|
||||
output, err := cmd.Output()
|
||||
|
||||
@@ -27,6 +27,7 @@ type ImageInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Downloading bool `json:"downloading"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ManualPath string `json:"manual_path,omitempty"`
|
||||
}
|
||||
|
||||
var imageDownloadsMu sync.Mutex
|
||||
@@ -122,6 +123,10 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
for _, t := range kvm.GetImages() {
|
||||
_, downloading := imageDownloads[t.ID]
|
||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||
manualPath := ""
|
||||
if t.Distro == "windows" {
|
||||
manualPath = kvm.ImagePath(t.ID)
|
||||
}
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
@@ -134,6 +139,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
Enabled: enabledSet[t.ID],
|
||||
Downloading: downloading,
|
||||
SizeBytes: size,
|
||||
ManualPath: manualPath,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,7 +188,9 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
ensureImageEnabled(image.ID)
|
||||
if err := kvm.DownloadImage(*image); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Download failed: " + err.Error()})
|
||||
message := "Download failed: " + err.Error()
|
||||
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -557,10 +558,10 @@ func countPorts(totalCounts map[int]int, destCounts map[int]map[string]int, port
|
||||
|
||||
func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-5 * time.Minute)
|
||||
shouldShutdown := false
|
||||
|
||||
for i := range ss.alerts {
|
||||
a := &ss.alerts[i]
|
||||
@@ -579,6 +580,11 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
|
||||
if severityRank(severity) > severityRank(a.Severity) {
|
||||
a.Severity = severity
|
||||
}
|
||||
shouldShutdown = config.AppConfig.SecurityAutoShutdown
|
||||
ss.mu.Unlock()
|
||||
if shouldShutdown {
|
||||
autoShutdownAlertContainer(name, alertType, severity)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -599,10 +605,16 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
|
||||
|
||||
ss.alerts = append(ss.alerts, alert)
|
||||
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
|
||||
shouldShutdown = config.AppConfig.SecurityAutoShutdown
|
||||
|
||||
if len(ss.alerts) > 200 {
|
||||
ss.alerts = ss.alerts[len(ss.alerts)-200:]
|
||||
}
|
||||
ss.mu.Unlock()
|
||||
|
||||
if shouldShutdown {
|
||||
autoShutdownAlertContainer(name, alertType, severity)
|
||||
}
|
||||
}
|
||||
|
||||
func severityRank(severity string) int {
|
||||
@@ -620,6 +632,22 @@ func severityRank(severity string) int {
|
||||
}
|
||||
}
|
||||
|
||||
func autoShutdownAlertContainer(containerName, alertType, severity string) {
|
||||
c := config.FindContainerByName(containerName)
|
||||
if c == nil || c.Status != "running" {
|
||||
return
|
||||
}
|
||||
reason := fmt.Sprintf("%s 告警触发策略临时封禁", alertType)
|
||||
if severity != "" {
|
||||
reason = fmt.Sprintf("[%s] %s", severity, reason)
|
||||
}
|
||||
config.SetContainerPolicyBlock(c.ID, true, reason)
|
||||
taskID, queued := globalQueue.EnqueueSecurityStop(c.ID, c.Name)
|
||||
if queued {
|
||||
config.AddAuditLog("security_auto_shutdown", c.Name, fmt.Sprintf("[%s] %s 告警触发自动关机任务 %s", severity, alertType, taskID), "system")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSecurityAlerts returns all security alerts.
|
||||
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -627,19 +655,35 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
reversed := make([]SecurityAlert, len(ss.alerts))
|
||||
for i, a := range ss.alerts {
|
||||
reversed[len(ss.alerts)-1-i] = a
|
||||
}
|
||||
ss.mu.Unlock()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
|
||||
}
|
||||
|
||||
if reversed == nil {
|
||||
reversed = []SecurityAlert{}
|
||||
// HandleSecuritySettings returns or updates security automation settings.
|
||||
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
}})
|
||||
case http.MethodPut:
|
||||
var req struct {
|
||||
AutoShutdown bool `json:"auto_shutdown"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
config.AppConfig.SecurityAutoShutdown = req.AutoShutdown
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
}})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
|
||||
}
|
||||
|
||||
// HandleSecurityCheck triggers immediate security check for a container.
|
||||
@@ -738,13 +782,12 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
critical := 0
|
||||
high := 0
|
||||
medium := 0
|
||||
low := 0
|
||||
for _, a := range ss.alerts {
|
||||
alerts := mergedSecurityAlerts()
|
||||
for _, a := range alerts {
|
||||
switch a.Severity {
|
||||
case "critical":
|
||||
critical++
|
||||
@@ -756,8 +799,7 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
low++
|
||||
}
|
||||
}
|
||||
total := len(ss.alerts)
|
||||
ss.mu.Unlock()
|
||||
total := len(alerts)
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"total_alerts": total,
|
||||
@@ -769,3 +811,117 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
||||
}
|
||||
|
||||
func mergedSecurityAlerts() []SecurityAlert {
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
alerts := make([]SecurityAlert, len(ss.alerts))
|
||||
copy(alerts, ss.alerts)
|
||||
ss.mu.Unlock()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, alert := range alerts {
|
||||
seen[securityAlertKey(alert)] = true
|
||||
}
|
||||
|
||||
for i, log := range config.AppConfig.AuditLogs {
|
||||
alert, ok := alertFromSecurityAuditLog(log, i)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := securityAlertKey(alert)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
|
||||
sort.SliceStable(alerts, func(i, j int) bool {
|
||||
ti, errI := time.Parse("2006-01-02 15:04:05", alerts[i].Timestamp)
|
||||
tj, errJ := time.Parse("2006-01-02 15:04:05", alerts[j].Timestamp)
|
||||
if errI == nil && errJ == nil && !ti.Equal(tj) {
|
||||
return ti.After(tj)
|
||||
}
|
||||
return alerts[i].Timestamp > alerts[j].Timestamp
|
||||
})
|
||||
|
||||
if len(alerts) > 200 {
|
||||
alerts = alerts[:200]
|
||||
}
|
||||
if alerts == nil {
|
||||
return []SecurityAlert{}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func securityAlertKey(alert SecurityAlert) string {
|
||||
return strings.Join([]string{
|
||||
alert.Timestamp,
|
||||
alert.ContainerName,
|
||||
alert.Type,
|
||||
alert.Detail,
|
||||
strconv.Itoa(alert.TargetPort),
|
||||
}, "\x1f")
|
||||
}
|
||||
|
||||
func alertFromSecurityAuditLog(log config.AuditLog, index int) (SecurityAlert, bool) {
|
||||
if !strings.HasPrefix(log.Action, "security_") || log.Action == "security_auto_shutdown" || log.Action == "security_policy_unblock" {
|
||||
return SecurityAlert{}, false
|
||||
}
|
||||
alertType := strings.TrimPrefix(log.Action, "security_")
|
||||
severity, detail := parseSecurityAuditDetail(log.Detail)
|
||||
targetPort := parseDetailPort(detail)
|
||||
|
||||
targetIP := ""
|
||||
if targetPort > 0 || alertType == "horizontal_scan" || alertType == "brute_force" {
|
||||
targetIP = "*"
|
||||
}
|
||||
|
||||
return SecurityAlert{
|
||||
ID: fmt.Sprintf("audit-security-%d", index),
|
||||
ContainerName: log.Target,
|
||||
Type: alertType,
|
||||
Severity: severity,
|
||||
SourceIP: "",
|
||||
TargetIP: targetIP,
|
||||
TargetPort: targetPort,
|
||||
Detail: detail,
|
||||
LogLine: "",
|
||||
Timestamp: log.Time,
|
||||
Count: 1,
|
||||
}, true
|
||||
}
|
||||
|
||||
func parseSecurityAuditDetail(detail string) (string, string) {
|
||||
severity := "medium"
|
||||
if strings.HasPrefix(detail, "[") {
|
||||
if end := strings.Index(detail, "]"); end > 1 {
|
||||
severity = detail[1:end]
|
||||
detail = strings.TrimSpace(detail[end+1:])
|
||||
}
|
||||
}
|
||||
return severity, detail
|
||||
}
|
||||
|
||||
func parseDetailPort(detail string) int {
|
||||
for _, marker := range []string{"端口 ", "端口"} {
|
||||
idx := strings.Index(detail, marker)
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
start := idx + len(marker)
|
||||
for start < len(detail) && (detail[start] == ' ' || detail[start] == ':' || detail[start] == '(') {
|
||||
start++
|
||||
}
|
||||
end := start
|
||||
for end < len(detail) && detail[end] >= '0' && detail[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end > start {
|
||||
port, _ := strconv.Atoi(detail[start:end])
|
||||
return port
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type terminalResizeMessage struct {
|
||||
|
||||
type webSSHTicket struct {
|
||||
ContainerName string
|
||||
SubUser bool
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -52,16 +53,22 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
if config.FindContainerByName(req.ContainerName) == nil {
|
||||
c := config.FindContainerByName(req.ContainerName)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) && c.PolicyBlocked {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
|
||||
return
|
||||
}
|
||||
|
||||
ticket := randomHex(32)
|
||||
webSSHTickets.Lock()
|
||||
cleanupExpiredWebSSHTicketsLocked(time.Now())
|
||||
webSSHTickets.items[ticket] = webSSHTicket{
|
||||
ContainerName: req.ContainerName,
|
||||
SubUser: isSubUserRequest(r),
|
||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||
}
|
||||
webSSHTickets.Unlock()
|
||||
@@ -86,7 +93,8 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !consumeWebSSHTicket(ticket, containerName) {
|
||||
item, ok := consumeWebSSHTicket(ticket, containerName)
|
||||
if !ok {
|
||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -96,6 +104,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "container not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if item.SubUser && c.PolicyBlocked {
|
||||
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c.Status != "running" {
|
||||
http.Error(w, "container is not running", http.StatusBadRequest)
|
||||
return
|
||||
@@ -359,17 +371,17 @@ func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) {
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
|
||||
}
|
||||
|
||||
func consumeWebSSHTicket(ticket, containerName string) bool {
|
||||
func consumeWebSSHTicket(ticket, containerName string) (webSSHTicket, bool) {
|
||||
now := time.Now()
|
||||
webSSHTickets.Lock()
|
||||
defer webSSHTickets.Unlock()
|
||||
cleanupExpiredWebSSHTicketsLocked(now)
|
||||
item, ok := webSSHTickets.items[ticket]
|
||||
if !ok {
|
||||
return false
|
||||
return webSSHTicket{}, false
|
||||
}
|
||||
delete(webSSHTickets.items, ticket)
|
||||
return item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
}
|
||||
|
||||
func cleanupExpiredWebSSHTicketsLocked(now time.Time) {
|
||||
|
||||
@@ -352,7 +352,11 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
action = strings.Join(parts[1:], "/")
|
||||
}
|
||||
if c.PolicyBlocked && isSubUserBlockedAction(action, r.Method) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
|
||||
return
|
||||
}
|
||||
if !isSubUserContainerActionAllowed(action, r.Method) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
|
||||
@@ -412,6 +416,25 @@ func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
||||
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
|
||||
}
|
||||
|
||||
func isSubUserBlockedAction(action string, method string) bool {
|
||||
if action == "" {
|
||||
return method != http.MethodGet
|
||||
}
|
||||
switch action {
|
||||
case "usage", "traffic":
|
||||
return method != http.MethodGet
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func policyBlockedMessage(c *config.Container) string {
|
||||
if c != nil && c.PolicyBlockedReason != "" {
|
||||
return "虚拟机被策略临时封禁:" + c.PolicyBlockedReason
|
||||
}
|
||||
return "虚拟机被策略临时封禁"
|
||||
}
|
||||
|
||||
func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
if action == "" {
|
||||
return method == http.MethodGet
|
||||
|
||||
@@ -196,6 +196,24 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
||||
return task.ID
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
for _, task := range q.tasks {
|
||||
if task.Type != TaskStop || task.ContainerID != containerID {
|
||||
continue
|
||||
}
|
||||
if task.Status == "pending" || task.Status == "running" {
|
||||
return task.ID, false
|
||||
}
|
||||
}
|
||||
|
||||
taskID := q.enqueueSingleWithAudit(containerID, containerName, TaskStop, "", "system:security", "", "")
|
||||
q.persistTasks()
|
||||
return taskID, true
|
||||
}
|
||||
|
||||
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
|
||||
// If a restored task already has a same-name container in config, it resumes
|
||||
// initialization instead of creating another ct-{id}.
|
||||
@@ -337,10 +355,14 @@ func (q *TaskQueue) opWorker() {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskStop:
|
||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||
case TaskRestart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskReinstall:
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
}
|
||||
}
|
||||
q.persistTasks()
|
||||
@@ -348,6 +370,17 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
func clearPolicyBlockAfterAdminRecovery(task *Task) {
|
||||
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
|
||||
return
|
||||
}
|
||||
c := config.FindContainer(task.ContainerID)
|
||||
if c != nil && c.PolicyBlocked {
|
||||
config.SetContainerPolicyBlock(c.ID, false, "")
|
||||
config.AddAuditLog("security_policy_unblock", c.Name, "管理员操作后解除策略临时封禁", task.User)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTaskContainer(task *Task) error {
|
||||
if task.Type == TaskCreate {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type webVNCTicket struct {
|
||||
ContainerName string
|
||||
ContainerUUID string
|
||||
SubUser bool
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var webVNCTickets = struct {
|
||||
sync.Mutex
|
||||
items map[string]webVNCTicket
|
||||
}{items: map[string]webVNCTicket{}}
|
||||
|
||||
func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"})
|
||||
return
|
||||
}
|
||||
if !isContainerAllowedForRequest(r, req.ContainerName) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
c := config.FindContainerByName(req.ContainerName)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) && c.PolicyBlocked {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
|
||||
return
|
||||
}
|
||||
if !c.IsKVM() {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"})
|
||||
return
|
||||
}
|
||||
|
||||
ticket := randomHex(32)
|
||||
webVNCTickets.Lock()
|
||||
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
||||
webVNCTickets.items[ticket] = webVNCTicket{
|
||||
ContainerName: c.Name,
|
||||
ContainerUUID: c.UUID,
|
||||
SubUser: isSubUserRequest(r),
|
||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||
}
|
||||
webVNCTickets.Unlock()
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"ticket": ticket},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleVNCProxy proxies a KVM VM's local libvirt VNC socket to the browser.
|
||||
func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := webVNCTicketFromRequest(r)
|
||||
if ticket == "" {
|
||||
http.Error(w, "ticket required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
containerName := r.URL.Query().Get("container")
|
||||
if containerName == "" {
|
||||
http.Error(w, "container name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
item, ok := consumeWebVNCTicket(ticket, containerName)
|
||||
if !ok {
|
||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
c := config.FindContainerByName(containerName)
|
||||
if c == nil || c.UUID != item.ContainerUUID {
|
||||
http.Error(w, "container not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if item.SubUser && c.PolicyBlocked {
|
||||
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !c.IsKVM() {
|
||||
http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.Status != "running" {
|
||||
http.Error(w, "container is not running", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
vncPort, err := kvmManager.RefreshVNCPort(c.ID)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("VNC display is not available: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
vncConn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", vncPort)), 5*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("VNC connection failed: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer vncConn.Close()
|
||||
|
||||
responseHeader := http.Header{}
|
||||
if protocol := webVNCResponseProtocol(r); protocol != "" {
|
||||
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, responseHeader)
|
||||
if err != nil {
|
||||
log.Printf("WebVNC upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort)
|
||||
|
||||
done := make(chan string, 2)
|
||||
var writeMu sync.Mutex
|
||||
go streamVNCToWebSocket(ws, &writeMu, vncConn, done)
|
||||
go streamWebSocketToVNC(ws, vncConn, done)
|
||||
|
||||
reason := <-done
|
||||
_ = vncConn.Close()
|
||||
_ = ws.Close()
|
||||
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason)
|
||||
}
|
||||
|
||||
func webVNCTicketFromRequest(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-vnc-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol[len(prefix):]
|
||||
}
|
||||
}
|
||||
return r.URL.Query().Get("ticket")
|
||||
}
|
||||
|
||||
func webVNCResponseProtocol(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
if protocol == "binary" {
|
||||
return protocol
|
||||
}
|
||||
}
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-vnc-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
||||
now := time.Now()
|
||||
webVNCTickets.Lock()
|
||||
defer webVNCTickets.Unlock()
|
||||
cleanupExpiredWebVNCTicketsLocked(now)
|
||||
item, ok := webVNCTickets.items[ticket]
|
||||
if !ok {
|
||||
return webVNCTicket{}, false
|
||||
}
|
||||
delete(webVNCTickets.items, ticket)
|
||||
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
}
|
||||
|
||||
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
||||
for ticket, item := range webVNCTickets.items {
|
||||
if !now.Before(item.ExpiresAt) {
|
||||
delete(webVNCTickets.items, ticket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamVNCToWebSocket(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- string) {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
writeMu.Lock()
|
||||
writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n])
|
||||
writeMu.Unlock()
|
||||
if writeErr != nil {
|
||||
done <- fmt.Sprintf("browser websocket write failed: %v", writeErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
done <- "VNC server closed connection"
|
||||
} else {
|
||||
done <- fmt.Sprintf("VNC server read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamWebSocketToVNC(ws *websocket.Conn, dst net.Conn, done chan<- string) {
|
||||
for {
|
||||
messageType, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
done <- fmt.Sprintf("browser websocket read failed: %v", err)
|
||||
return
|
||||
}
|
||||
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
if _, err := dst.Write(msg); err != nil {
|
||||
done <- fmt.Sprintf("VNC server write failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
-2
@@ -681,7 +681,7 @@ func cliImportExistingContainers() {
|
||||
func cliUninstall(reader *bufio.Reader) {
|
||||
fmt.Println("\n--- 卸载 CLICD ---")
|
||||
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
|
||||
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc 下全部 LXC 容器,以及 /var/cache/lxc 镜像缓存。")
|
||||
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("卸载需要 root 权限。")
|
||||
@@ -696,6 +696,10 @@ func cliUninstall(reader *bufio.Reader) {
|
||||
}
|
||||
|
||||
destroyAllLXCContainers()
|
||||
destroyAllKVMDomains()
|
||||
cleanupCLICDNetworking()
|
||||
removeCLICDHostHooks()
|
||||
removeCLICDQuotaRecords()
|
||||
stopAndRemoveService()
|
||||
removePath("/usr/local/bin/clicd")
|
||||
removePath("/etc/sysctl.d/99-clicd.conf")
|
||||
@@ -703,13 +707,18 @@ func cliUninstall(reader *bufio.Reader) {
|
||||
removePath("/var/log/clicd.err")
|
||||
removePath("/root/.clicd")
|
||||
removePath("/var/lib/lxc")
|
||||
removePath("/var/lib/clicd")
|
||||
removePath("/var/cache/lxc")
|
||||
removePath("/var/cache/clicd")
|
||||
removePath("/root/clicd-backups")
|
||||
removeCLICDTmpFiles()
|
||||
removeCLICDSwapfile()
|
||||
|
||||
reloadSysctl()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("CLICD 已卸载。")
|
||||
fmt.Println("服务、二进制、配置、容器和 LXC 镜像缓存均已删除。")
|
||||
fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
|
||||
}
|
||||
|
||||
func destroyAllLXCContainers() {
|
||||
@@ -730,6 +739,151 @@ func destroyAllLXCContainers() {
|
||||
}
|
||||
}
|
||||
|
||||
func destroyAllKVMDomains() {
|
||||
if !commandExists("virsh") {
|
||||
return
|
||||
}
|
||||
out, err := exec.Command("virsh", "list", "--all", "--name").Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
name := strings.TrimSpace(line)
|
||||
if isCLICDKVMDomain(name) {
|
||||
removeKVMDomain(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isCLICDKVMDomain(name string) bool {
|
||||
if !strings.HasPrefix(name, "vm-") || len(name) <= len("vm-") {
|
||||
return false
|
||||
}
|
||||
for _, r := range strings.TrimPrefix(name, "vm-") {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if dirExists("/var/lib/clicd/kvm/instances/" + name) {
|
||||
return true
|
||||
}
|
||||
out, err := exec.Command("virsh", "dumpxml", name).Output()
|
||||
return err == nil && strings.Contains(string(out), "/var/lib/clicd/kvm/")
|
||||
}
|
||||
|
||||
func removeKVMDomain(name string) {
|
||||
fmt.Printf("Removing KVM domain %s...\n", name)
|
||||
runQuiet("virsh", "destroy", name)
|
||||
if runCommandOK("virsh", "undefine", name, "--remove-all-storage", "--nvram") {
|
||||
return
|
||||
}
|
||||
if runCommandOK("virsh", "undefine", name, "--nvram") {
|
||||
return
|
||||
}
|
||||
runQuiet("virsh", "undefine", name)
|
||||
}
|
||||
|
||||
func cleanupCLICDNetworking() {
|
||||
removeCLICDNATRules()
|
||||
for _, bridge := range []string{"lxcbr0", "virbr0"} {
|
||||
deleteFilterRule("FORWARD", "-i", bridge, "-j", "ACCEPT")
|
||||
deleteFilterRule("FORWARD", "-o", bridge, "-j", "ACCEPT")
|
||||
deleteFilterRule("FORWARD", "-i", bridge, "-o", bridge, "-j", "ACCEPT")
|
||||
deleteIP6TablesBridgeRules(bridge)
|
||||
}
|
||||
}
|
||||
|
||||
func removeCLICDNATRules() {
|
||||
if commandExists("iptables") {
|
||||
for {
|
||||
out, err := exec.Command("sh", "-c", "iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-' | awk '{print $1}' | head -n 1").Output()
|
||||
line := strings.TrimSpace(string(out))
|
||||
if err != nil || line == "" {
|
||||
break
|
||||
}
|
||||
if !runCommandOK("iptables", "-t", "nat", "-D", "PREROUTING", line) {
|
||||
break
|
||||
}
|
||||
}
|
||||
deleteNATRule("POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE")
|
||||
deleteNATRule("POSTROUTING", "-s", "192.168.122.0/24", "-o", "eth+", "-j", "MASQUERADE")
|
||||
}
|
||||
}
|
||||
|
||||
func deleteNATRule(args ...string) {
|
||||
fullArgs := append([]string{"-t", "nat", "-D"}, args...)
|
||||
for runCommandOK("iptables", fullArgs...) {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteFilterRule(args ...string) {
|
||||
fullArgs := append([]string{"-D"}, args...)
|
||||
for runCommandOK("iptables", fullArgs...) {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteIP6TablesBridgeRules(bridge string) {
|
||||
if !commandExists("ip6tables") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
cmd := fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep -- %s | sed 's/^-A /-D /' | head -n 1", shellQuote(bridge))
|
||||
out, err := exec.Command("sh", "-c", cmd).Output()
|
||||
rule := strings.TrimSpace(string(out))
|
||||
if err != nil || rule == "" {
|
||||
return
|
||||
}
|
||||
if !runCommandOK("sh", "-c", "ip6tables "+rule) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeCLICDHostHooks() {
|
||||
runQuiet("systemctl", "stop", "clicd-kvm-ipv6.service")
|
||||
runQuiet("systemctl", "disable", "clicd-kvm-ipv6.service")
|
||||
runQuiet("rc-service", "clicd-kvm-ipv6", "stop")
|
||||
runQuiet("rc-update", "del", "clicd-kvm-ipv6", "default")
|
||||
removePath("/usr/local/sbin/clicd-kvm-ipv6-init")
|
||||
removePath("/etc/systemd/system/clicd-kvm-ipv6.service")
|
||||
removePath("/etc/local.d/clicd-kvm-ipv6.start")
|
||||
removePath("/etc/network/if-up.d/clicd-kvm-ipv6")
|
||||
}
|
||||
|
||||
func removeCLICDQuotaRecords() {
|
||||
for _, path := range []string{"/etc/projects", "/etc/projid"} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var kept []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == "" || strings.Contains(line, "clicd-") {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
_ = os.WriteFile(path, []byte(strings.Join(kept, "\n")+"\n"), 0644)
|
||||
}
|
||||
}
|
||||
|
||||
func removeCLICDTmpFiles() {
|
||||
for _, pattern := range []string{"/tmp/clicd-*", "/tmp/clicd.*"} {
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
for _, path := range matches {
|
||||
removePath(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeCLICDSwapfile() {
|
||||
if !fileExists("/swapfile") {
|
||||
return
|
||||
}
|
||||
runQuiet("swapoff", "/swapfile")
|
||||
removePath("/swapfile")
|
||||
}
|
||||
|
||||
func removeLXCContainerPath(path string) {
|
||||
unmountPathTree(path)
|
||||
detachLoopDevices(path)
|
||||
@@ -819,6 +973,16 @@ func removePath(path string) {
|
||||
fmt.Printf("Removed %s\n", path)
|
||||
}
|
||||
|
||||
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 reloadSysctl() {
|
||||
if commandExists("sysctl") {
|
||||
runQuiet("sysctl", "--system")
|
||||
@@ -830,10 +994,18 @@ func commandExists(name string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func runCommandOK(name string, args ...string) bool {
|
||||
return exec.Command(name, args...).Run() == nil
|
||||
}
|
||||
|
||||
func runQuiet(name string, args ...string) {
|
||||
_ = exec.Command(name, args...).Run()
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
func restartWebPanelForConfigChange() {
|
||||
if err := restartService("clicd"); err != nil {
|
||||
fmt.Printf("Web 面板重载跳过: %v\n", err)
|
||||
|
||||
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -58,6 +57,13 @@ type AuditLog struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type VMReadinessCheck struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Container represents an LXC container configuration
|
||||
type Container struct {
|
||||
ID int `json:"id"`
|
||||
@@ -101,6 +107,9 @@ type Container struct {
|
||||
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
|
||||
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
|
||||
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
||||
PolicyBlocked bool `json:"policy_blocked"`
|
||||
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
|
||||
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -191,23 +200,24 @@ type Snapshot struct {
|
||||
|
||||
// ClicdConfig is the main configuration structure
|
||||
type ClicdConfig struct {
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
}
|
||||
|
||||
var configPath string
|
||||
@@ -270,69 +280,91 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
dataDir := getDataDir()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %v", err)
|
||||
return nil, fmt.Errorf("failed to create data directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %v", err)
|
||||
}
|
||||
if err := openConfigDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
// First run: generate new config
|
||||
adminUser := "admin"
|
||||
adminPass := generateRandomString(16)
|
||||
jwtSecret := generateRandomString(32)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %v", err)
|
||||
cfg, ok, err := loadConfigFromDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
AppConfig = cfg
|
||||
normalizeConfigDefaults(dataDir)
|
||||
if migrateLoadedConfig() {
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig = &ClicdConfig{
|
||||
AdminUser: adminUser,
|
||||
AdminPassHash: string(hash),
|
||||
JWTSecret: jwtSecret,
|
||||
Port: 8999,
|
||||
DataDir: dataDir,
|
||||
Containers: []Container{},
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
Tasks: []SavedTask{},
|
||||
LoginLogs: []SavedLoginLog{},
|
||||
Snapshots: []Snapshot{},
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println(" CLICD - LXC Container Manager")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf(" Username: %s\n", adminUser)
|
||||
fmt.Printf(" Password: %s\n", adminPass)
|
||||
fmt.Println("========================================")
|
||||
fmt.Println(" Please save these credentials!")
|
||||
fmt.Println(" Web Interface: http://0.0.0.0:8999")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
// Load existing config
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
legacy, ok, err := loadLegacyJSONConfig(cfgPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
AppConfig = legacy
|
||||
normalizeConfigDefaults(dataDir)
|
||||
if migrateLoadedConfig() {
|
||||
// Save below persists normalized legacy data into SQLite.
|
||||
}
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
AppConfig = &ClicdConfig{}
|
||||
if err := json.Unmarshal(data, AppConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %v", err)
|
||||
adminUser := "admin"
|
||||
adminPass := generateRandomString(16)
|
||||
jwtSecret := generateRandomString(32)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
AppConfig = &ClicdConfig{
|
||||
AdminUser: adminUser,
|
||||
AdminPassHash: string(hash),
|
||||
JWTSecret: jwtSecret,
|
||||
Port: 8999,
|
||||
DataDir: dataDir,
|
||||
Containers: []Container{},
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
Tasks: []SavedTask{},
|
||||
LoginLogs: []SavedLoginLog{},
|
||||
Snapshots: []Snapshot{},
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println(" CLICD - LXC Container Manager")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf(" Username: %s\n", adminUser)
|
||||
fmt.Printf(" Password: %s\n", adminPass)
|
||||
fmt.Println("========================================")
|
||||
fmt.Println(" Please save these credentials!")
|
||||
fmt.Println(" Web Interface: http://0.0.0.0:8999")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
func normalizeConfigDefaults(dataDir string) {
|
||||
if AppConfig.Port == 0 {
|
||||
AppConfig.Port = 8999
|
||||
}
|
||||
@@ -354,6 +386,27 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
if AppConfig.Snapshots == nil {
|
||||
AppConfig.Snapshots = make([]Snapshot, 0)
|
||||
}
|
||||
if AppConfig.SubUsers == nil {
|
||||
AppConfig.SubUsers = make([]SubUser, 0)
|
||||
}
|
||||
if AppConfig.ApiKeys == nil {
|
||||
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
||||
}
|
||||
if AppConfig.AuditLogs == nil {
|
||||
AppConfig.AuditLogs = make([]AuditLog, 0)
|
||||
}
|
||||
if AppConfig.Tasks == nil {
|
||||
AppConfig.Tasks = make([]SavedTask, 0)
|
||||
}
|
||||
if AppConfig.LoginLogs == nil {
|
||||
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
|
||||
}
|
||||
if AppConfig.EnabledImages == nil {
|
||||
AppConfig.EnabledImages = make([]string, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateLoadedConfig() bool {
|
||||
changed := ensureContainerUUIDs()
|
||||
if ensureContainerVirtualization() {
|
||||
changed = true
|
||||
@@ -373,13 +426,7 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
if removeLegacyVNCMappings() {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return AppConfig, nil
|
||||
return changed
|
||||
}
|
||||
|
||||
func ensureContainerVirtualization() bool {
|
||||
@@ -530,11 +577,7 @@ func removeLegacyVNCMappings() bool {
|
||||
|
||||
// SaveConfig saves configuration to disk
|
||||
func SaveConfig() error {
|
||||
data, err := json.MarshalIndent(AppConfig, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %v", err)
|
||||
}
|
||||
return os.WriteFile(getConfigPath(), data, 0600)
|
||||
return saveConfigToDB()
|
||||
}
|
||||
|
||||
// AddContainer adds a container to the config
|
||||
@@ -710,6 +753,22 @@ func UpdateContainerStatus(id int, status string) {
|
||||
}
|
||||
}
|
||||
|
||||
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
|
||||
c := FindContainer(id)
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.PolicyBlocked = blocked
|
||||
if blocked {
|
||||
c.PolicyBlockedReason = reason
|
||||
c.PolicyBlockedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
} else {
|
||||
c.PolicyBlockedReason = ""
|
||||
c.PolicyBlockedAt = ""
|
||||
}
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
// UpdateVNC refreshes all container statuses
|
||||
func UpdateVNC(containers []Container) {
|
||||
AppConfig.Containers = containers
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
dbMu sync.Mutex
|
||||
db *sql.DB
|
||||
)
|
||||
|
||||
type savedTaskConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||
if raw == "" {
|
||||
return savedTaskConfig{}
|
||||
}
|
||||
var cfg savedTaskConfig
|
||||
_ = json.Unmarshal([]byte(raw), &cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func getDBPath() string {
|
||||
cfgPath := getConfigPath()
|
||||
ext := filepath.Ext(cfgPath)
|
||||
if ext == "" {
|
||||
return cfgPath + ".db"
|
||||
}
|
||||
return strings.TrimSuffix(cfgPath, ext) + ".db"
|
||||
}
|
||||
|
||||
func openConfigDB() error {
|
||||
if db != nil {
|
||||
return nil
|
||||
}
|
||||
dbPath := getDBPath()
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create database directory: %v", err)
|
||||
}
|
||||
next, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open sqlite database: %v", err)
|
||||
}
|
||||
next.SetMaxOpenConns(1)
|
||||
next.SetMaxIdleConns(1)
|
||||
|
||||
for _, stmt := range []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
} {
|
||||
if _, err := next.Exec(stmt); err != nil {
|
||||
_ = next.Close()
|
||||
return fmt.Errorf("failed to initialize sqlite pragma: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
db = next
|
||||
return ensureSchema()
|
||||
}
|
||||
|
||||
func ensureSchema() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS containers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
virtualization TEXT,
|
||||
lxc_name TEXT,
|
||||
kvm_name TEXT,
|
||||
disk_image TEXT,
|
||||
mac_address TEXT,
|
||||
template TEXT,
|
||||
vcpu REAL,
|
||||
ram_mb INTEGER,
|
||||
disk_gb INTEGER,
|
||||
network_bw_mbps INTEGER,
|
||||
monthly_traffic_gb INTEGER,
|
||||
traffic_mode TEXT,
|
||||
traffic_in_gb INTEGER,
|
||||
traffic_out_gb INTEGER,
|
||||
traffic_used_rx INTEGER,
|
||||
traffic_used_tx INTEGER,
|
||||
traffic_reset_date TEXT,
|
||||
io_speed_mbps INTEGER,
|
||||
status TEXT,
|
||||
ip TEXT,
|
||||
ipv6 TEXT,
|
||||
ipv6_prefix_len INTEGER,
|
||||
ipv6_interface TEXT,
|
||||
vnc_port INTEGER,
|
||||
ssh_port INTEGER,
|
||||
ssh_password TEXT,
|
||||
ssh_host_key TEXT,
|
||||
port_mapping_limit INTEGER,
|
||||
snapshot_limit INTEGER,
|
||||
created_at TEXT,
|
||||
expires_at TEXT,
|
||||
snapshot_schedule_enabled INTEGER,
|
||||
snapshot_schedule_interval_hours INTEGER,
|
||||
snapshot_schedule_time TEXT,
|
||||
snapshot_schedule_last_run TEXT,
|
||||
snapshot_schedule_next_run TEXT,
|
||||
snapshot_schedule_created_by TEXT,
|
||||
policy_blocked INTEGER,
|
||||
policy_blocked_reason TEXT,
|
||||
policy_blocked_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS port_mappings (
|
||||
container_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
container_port INTEGER NOT NULL,
|
||||
host_port INTEGER NOT NULL,
|
||||
protocol TEXT,
|
||||
description TEXT,
|
||||
PRIMARY KEY (container_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
pass_hash TEXT,
|
||||
access_code TEXT,
|
||||
created_at TEXT,
|
||||
token_version INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
|
||||
sub_user_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
container_name TEXT NOT NULL,
|
||||
PRIMARY KEY (sub_user_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_user_container_uuids (
|
||||
sub_user_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
container_uuid TEXT NOT NULL,
|
||||
PRIMARY KEY (sub_user_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
key_hash TEXT,
|
||||
prefix TEXT,
|
||||
ip_whitelist TEXT,
|
||||
created_at TEXT,
|
||||
last_used TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT,
|
||||
action TEXT,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
user TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
success_set INTEGER,
|
||||
success INTEGER,
|
||||
error TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT,
|
||||
container_id INTEGER,
|
||||
container_name TEXT,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT,
|
||||
template_id TEXT,
|
||||
user TEXT,
|
||||
cfg_name TEXT,
|
||||
cfg_virtualization TEXT,
|
||||
cfg_template_id TEXT,
|
||||
cfg_vcpu REAL,
|
||||
cfg_cpu_percent INTEGER,
|
||||
cfg_ram_mb INTEGER,
|
||||
cfg_disk_gb INTEGER,
|
||||
cfg_network_bw_mbps INTEGER,
|
||||
cfg_monthly_traffic_gb INTEGER,
|
||||
cfg_traffic_mode TEXT,
|
||||
cfg_traffic_in_gb INTEGER,
|
||||
cfg_traffic_out_gb INTEGER,
|
||||
cfg_io_speed_mbps INTEGER,
|
||||
cfg_port_mapping_count INTEGER,
|
||||
cfg_snapshot_limit INTEGER,
|
||||
cfg_assign_ipv6 INTEGER,
|
||||
cfg_expires_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||
task_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
PRIMARY KEY (task_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS login_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT,
|
||||
username TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS enabled_images (
|
||||
position INTEGER PRIMARY KEY,
|
||||
image_id TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
container_id INTEGER,
|
||||
container_name TEXT,
|
||||
lxc_name TEXT,
|
||||
created_at TEXT,
|
||||
created_by TEXT,
|
||||
scheduled INTEGER,
|
||||
path TEXT,
|
||||
size_bytes INTEGER
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
meta := map[string]string{}
|
||||
rows, err := db.Query("SELECT key, value FROM app_meta")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
meta[k] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if meta["admin_user"] == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
cfg := &ClicdConfig{
|
||||
AdminUser: meta["admin_user"],
|
||||
AdminPassHash: meta["admin_pass_hash"],
|
||||
JWTSecret: meta["jwt_secret"],
|
||||
Port: atoi(meta["port"]),
|
||||
DataDir: meta["data_dir"],
|
||||
NextContainerID: atoi(meta["next_container_id"]),
|
||||
NextVNCPort: atoi(meta["next_vnc_port"]),
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
}
|
||||
|
||||
if cfg.Containers, err = loadContainers(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.SubUsers, err = loadSubUsers(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.ApiKeys, err = loadAPIKeys(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.AuditLogs, err = loadAuditLogs(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.Tasks, err = loadTasks(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.LoginLogs, err = loadLoginLogs(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.EnabledImages, err = loadEnabledImages(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.Snapshots, err = loadSnapshots(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
func saveConfigToDB() error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("sqlite database is not initialized")
|
||||
}
|
||||
dbMu.Lock()
|
||||
defer dbMu.Unlock()
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, table := range []string{
|
||||
"port_mappings",
|
||||
"sub_user_container_names",
|
||||
"sub_user_container_uuids",
|
||||
"containers",
|
||||
"sub_users",
|
||||
"api_keys",
|
||||
"audit_logs",
|
||||
"task_extra_ports",
|
||||
"tasks",
|
||||
"login_logs",
|
||||
"enabled_images",
|
||||
"snapshots",
|
||||
"app_meta",
|
||||
} {
|
||||
if _, err := tx.Exec("DELETE FROM " + table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := saveMeta(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveContainers(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveSubUsers(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveAPIKeys(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveAuditLogs(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveTasksDB(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveLoginLogs(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveEnabledImages(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveSnapshots(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func saveMeta(tx *sql.Tx) error {
|
||||
values := map[string]string{
|
||||
"admin_user": AppConfig.AdminUser,
|
||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||
"jwt_secret": AppConfig.JWTSecret,
|
||||
"port": strconv.Itoa(AppConfig.Port),
|
||||
"data_dir": AppConfig.DataDir,
|
||||
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
|
||||
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"schema_version": "1",
|
||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
for k, v := range values {
|
||||
if _, err := tx.Exec("INSERT INTO app_meta(key, value) VALUES (?, ?)", k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveContainers(tx *sql.Tx) error {
|
||||
for _, c := range AppConfig.Containers {
|
||||
if _, err := tx.Exec(`INSERT INTO containers (
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
|
||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
|
||||
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
||||
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
|
||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
||||
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, pm := range c.PortMappings {
|
||||
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveSubUsers(tx *sql.Tx) error {
|
||||
for _, su := range AppConfig.SubUsers {
|
||||
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, name := range su.ContainerNames {
|
||||
if _, err := tx.Exec(`INSERT INTO sub_user_container_names(sub_user_id, position, container_name) VALUES (?, ?, ?)`, su.ID, i, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, uuid := range su.ContainerUUIDs {
|
||||
if _, err := tx.Exec(`INSERT INTO sub_user_container_uuids(sub_user_id, position, container_uuid) VALUES (?, ?, ?)`, su.ID, i, uuid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAPIKeys(tx *sql.Tx) error {
|
||||
for _, k := range AppConfig.ApiKeys {
|
||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAuditLogs(tx *sql.Tx) error {
|
||||
for _, log := range AppConfig.AuditLogs {
|
||||
successSet := 0
|
||||
success := 0
|
||||
if log.Success != nil {
|
||||
successSet = 1
|
||||
if *log.Success {
|
||||
success = 1
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO audit_logs(time, action, target, detail, user, ip, user_agent, success_set, success, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, log.Time, log.Action, log.Target, log.Detail, log.User, log.IP, log.UserAgent, successSet, success, log.Error); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveTasksDB(tx *sql.Tx) error {
|
||||
for _, task := range AppConfig.Tasks {
|
||||
cfg := parseSavedTaskConfig(task.Config)
|
||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
cfg_assign_ipv6, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
|
||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
||||
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, port := range cfg.ExtraPorts {
|
||||
if _, err := tx.Exec(`INSERT INTO task_extra_ports(task_id, position, port) VALUES (?, ?, ?)`, task.ID, i, port); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveLoginLogs(tx *sql.Tx) error {
|
||||
for _, log := range AppConfig.LoginLogs {
|
||||
if _, err := tx.Exec(`INSERT INTO login_logs(time, username, ip, user_agent, success) VALUES (?, ?, ?, ?, ?)`,
|
||||
log.Time, log.Username, log.IP, log.UserAgent, boolInt(log.Success)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveEnabledImages(tx *sql.Tx) error {
|
||||
for i, id := range AppConfig.EnabledImages {
|
||||
if _, err := tx.Exec(`INSERT INTO enabled_images(position, image_id) VALUES (?, ?)`, i, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveSnapshots(tx *sql.Tx) error {
|
||||
for _, snapshot := range AppConfig.Snapshots {
|
||||
if _, err := tx.Exec(`INSERT INTO snapshots(id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.ID, snapshot.ContainerID, snapshot.ContainerName, snapshot.LXCName, snapshot.CreatedAt, snapshot.CreatedBy, boolInt(snapshot.Scheduled), snapshot.Path, snapshot.SizeBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadContainers() ([]Container, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
|
||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at
|
||||
FROM containers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := []Container{}
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
var scheduleEnabled, policyBlocked int
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps,
|
||||
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||
c.PolicyBlocked = policyBlocked != 0
|
||||
result = append(result, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range result {
|
||||
result[i].PortMappings, err = loadPortMappings(result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadPortMappings(containerID int) ([]PortMapping, error) {
|
||||
rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []PortMapping{}
|
||||
for rows.Next() {
|
||||
var pm PortMapping
|
||||
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, pm)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSubUsers() ([]SubUser, error) {
|
||||
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []SubUser{}
|
||||
for rows.Next() {
|
||||
var su SubUser
|
||||
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, su)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range result {
|
||||
result[i].ContainerNames, err = loadStringList("sub_user_container_names", "container_name", "sub_user_id", result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].ContainerUUIDs, err = loadStringList("sub_user_container_uuids", "container_uuid", "sub_user_id", result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error) {
|
||||
rows, err := db.Query(fmt.Sprintf(`SELECT %s FROM %s WHERE %s = ? ORDER BY position`, valueColumn, table, keyColumn), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := []string{}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
func loadAPIKeys() ([]ApiKeyConfig, error) {
|
||||
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []ApiKeyConfig{}
|
||||
for rows.Next() {
|
||||
var k ApiKeyConfig
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, k)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadAuditLogs() ([]AuditLog, error) {
|
||||
rows, err := db.Query(`SELECT time, action, target, detail, user, ip, user_agent, success_set, success, error FROM audit_logs ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []AuditLog{}
|
||||
for rows.Next() {
|
||||
var log AuditLog
|
||||
var successSet, success int
|
||||
if err := rows.Scan(&log.Time, &log.Action, &log.Target, &log.Detail, &log.User, &log.IP, &log.UserAgent, &successSet, &success, &log.Error); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if successSet != 0 {
|
||||
value := success != 0
|
||||
log.Success = &value
|
||||
}
|
||||
result = append(result, log)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadTasks() ([]SavedTask, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
cfg_assign_ipv6, cfg_expires_at
|
||||
FROM tasks ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []SavedTask{}
|
||||
configs := []savedTaskConfig{}
|
||||
for rows.Next() {
|
||||
var t SavedTask
|
||||
var cfg savedTaskConfig
|
||||
var assignIPv6 int
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User,
|
||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
||||
&assignIPv6, &cfg.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.AssignIPv6 = assignIPv6 != 0
|
||||
result = append(result, t)
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range result {
|
||||
configs[i].ExtraPorts, err = loadTaskExtraPorts(result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].Config = encodeSavedTaskConfig(configs[i])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadTaskExtraPorts(taskID string) ([]int, error) {
|
||||
rows, err := db.Query(`SELECT port FROM task_extra_ports WHERE task_id = ? ORDER BY position`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []int{}
|
||||
for rows.Next() {
|
||||
var port int
|
||||
if err := rows.Scan(&port); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, port)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadLoginLogs() ([]SavedLoginLog, error) {
|
||||
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []SavedLoginLog{}
|
||||
for rows.Next() {
|
||||
var log SavedLoginLog
|
||||
var success int
|
||||
if err := rows.Scan(&log.Time, &log.Username, &log.IP, &log.UserAgent, &success); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Success = success != 0
|
||||
result = append(result, log)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadEnabledImages() ([]string, error) {
|
||||
rows, err := db.Query(`SELECT image_id FROM enabled_images ORDER BY position`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSnapshots() ([]Snapshot, error) {
|
||||
rows, err := db.Query(`SELECT id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes FROM snapshots ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Snapshot{}
|
||||
for rows.Next() {
|
||||
var snapshot Snapshot
|
||||
var scheduled int
|
||||
if err := rows.Scan(&snapshot.ID, &snapshot.ContainerID, &snapshot.ContainerName, &snapshot.LXCName, &snapshot.CreatedAt, &snapshot.CreatedBy, &scheduled, &snapshot.Path, &snapshot.SizeBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot.Scheduled = scheduled != 0
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadLegacyJSONConfig(path string) (*ClicdConfig, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to read legacy config: %v", err)
|
||||
}
|
||||
cfg := &ClicdConfig{}
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to parse legacy config: %v", err)
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func btoa(value bool) string {
|
||||
if value {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func atob(value string) bool {
|
||||
return value == "1" || strings.EqualFold(value, "true")
|
||||
}
|
||||
|
||||
func atoi(value string) int {
|
||||
n, _ := strconv.Atoi(value)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
resetConfigStoreForTest(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Cleanup(func() {
|
||||
resetConfigStoreForTest(t)
|
||||
})
|
||||
legacyPath := filepath.Join(dir, "config.json")
|
||||
SetConfigPath(legacyPath)
|
||||
|
||||
legacy := ClicdConfig{
|
||||
AdminUser: "admin",
|
||||
AdminPassHash: "hash",
|
||||
JWTSecret: "secret",
|
||||
Port: 8999,
|
||||
DataDir: dir,
|
||||
NextContainerID: 2,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
Containers: []Container{{
|
||||
ID: 1,
|
||||
UUID: "uuid-1",
|
||||
Name: "ct1",
|
||||
Virtualization: "lxc",
|
||||
Template: "debian-12",
|
||||
Status: "running",
|
||||
PortMappingLimit: 2,
|
||||
SnapshotLimit: 3,
|
||||
PortMappings: []PortMapping{{
|
||||
ContainerPort: 22,
|
||||
HostPort: 22001,
|
||||
Protocol: "tcp",
|
||||
Description: "SSH",
|
||||
}},
|
||||
}},
|
||||
AuditLogs: []AuditLog{{
|
||||
Time: "2026-06-07 17:29:00",
|
||||
Action: "security_horizontal_scan",
|
||||
Target: "ct1",
|
||||
Detail: "[medium] 可疑横向探测",
|
||||
User: "system",
|
||||
}},
|
||||
LoginLogs: []SavedLoginLog{{
|
||||
Time: "2026-06-07 17:29:01 CST",
|
||||
Username: "admin",
|
||||
IP: "127.0.0.1",
|
||||
UserAgent: "test",
|
||||
Success: true,
|
||||
}},
|
||||
Tasks: []SavedTask{{
|
||||
ID: "task-1",
|
||||
Type: "create",
|
||||
ContainerName: "ct2",
|
||||
Status: "pending",
|
||||
CreatedAt: "2026-06-07 17:29:02",
|
||||
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"assign_ipv6":true}`,
|
||||
}},
|
||||
EnabledImages: []string{"debian-12"},
|
||||
Snapshots: []Snapshot{{
|
||||
ID: "snap-1",
|
||||
ContainerID: 1,
|
||||
ContainerName: "ct1",
|
||||
LXCName: "ct-1",
|
||||
CreatedAt: "2026-06-07 17:30:00",
|
||||
Path: filepath.Join(dir, "snap-1"),
|
||||
}},
|
||||
}
|
||||
data, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyPath, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := InitConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Containers) != 1 || len(cfg.Containers[0].PortMappings) != 1 {
|
||||
t.Fatalf("legacy config was not migrated: %+v", cfg.Containers)
|
||||
}
|
||||
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
|
||||
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
|
||||
t.Fatalf("sqlite database was not created: %v", err)
|
||||
}
|
||||
|
||||
cfg.Containers[0].Status = "stopped"
|
||||
if err := SaveConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetConfigStoreForTest(t)
|
||||
SetConfigPath(legacyPath)
|
||||
cfg, err = InitConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.Containers[0].Status; got != "stopped" {
|
||||
t.Fatalf("expected sqlite value to win after migration, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func resetConfigStoreForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
if db != nil {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db = nil
|
||||
}
|
||||
AppConfig = nil
|
||||
configPath = ""
|
||||
}
|
||||
+1012
-58
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,12 @@ func GetImages() []Image {
|
||||
Description: "Rocky Linux 9 GenericCloud image for KVM",
|
||||
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-windows-10", Name: "Windows 10 KVM",
|
||||
Distro: "windows", Release: "10", Arch: "amd64",
|
||||
Description: "Windows 10 Enterprise LTSC Evaluation",
|
||||
URL: "https://go.microsoft.com/fwlink/?LinkID=2195404",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,5 +93,20 @@ func CacheDir() string {
|
||||
}
|
||||
|
||||
func ImagePath(id string) string {
|
||||
return filepath.Join(CacheDir(), id+".qcow2")
|
||||
img := FindImage(id)
|
||||
ext := ".qcow2"
|
||||
if img != nil && img.Distro == "windows" {
|
||||
ext = ".iso"
|
||||
}
|
||||
return filepath.Join(CacheDir(), id+ext)
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image distro is "windows".
|
||||
func IsWindowsImage(id string) bool {
|
||||
img := FindImage(id)
|
||||
return img != nil && img.Distro == "windows"
|
||||
}
|
||||
|
||||
func virtioWinISOPath() string {
|
||||
return filepath.Join(CacheDir(), "virtio-win.iso")
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
@@ -1004,7 +1005,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rootStat, ok := rootInfo.Sys().(*syscall.Stat_t)
|
||||
rootStat, ok := rootInfo.Sys().(*unix.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
|
||||
}
|
||||
@@ -1018,7 +1019,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
stat, ok := info.Sys().(*unix.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to read uid/gid for %s", path)
|
||||
}
|
||||
@@ -1039,7 +1040,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if gid >= 0 && gid < 65536 {
|
||||
gid += gidBase
|
||||
}
|
||||
return syscall.Lchown(path, uid, gid)
|
||||
return unix.Lchown(path, uid, gid)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
|
||||
}
|
||||
@@ -1047,7 +1048,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syscall.Lchown(marker, uidBase, gidBase); err != nil {
|
||||
if err := unix.Lchown(marker, uidBase, gidBase); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,11 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
|
||||
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
|
||||
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
|
||||
mux.HandleFunc("/api/security/settings", corsMiddleware(api.AdminMiddleware(api.HandleSecuritySettings)))
|
||||
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
|
||||
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
|
||||
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
|
||||
mux.HandleFunc("/api/vnc", api.HandleVNCProxy) // WebSocket
|
||||
|
||||
// API Key management
|
||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||
@@ -161,4 +164,3 @@ func Run() error {
|
||||
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.0.12"
|
||||
Version = "1.1.0"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
@@ -18,3 +18,5 @@ func Current() string {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ func main() {
|
||||
// Start usage monitors (computes CPU/network/disk rates every 5s)
|
||||
manager.StartUsageMonitor()
|
||||
kvmManager.StartUsageMonitor()
|
||||
kvmManager.StartNetworkSyncMonitor()
|
||||
kvmManager.StartIPv6Guard()
|
||||
|
||||
// Start scheduled snapshot scanners.
|
||||
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.6.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
@@ -801,6 +802,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@novnc/novnc": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.6.0.tgz",
|
||||
"integrity": "sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.6.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
|
||||
@@ -48,7 +48,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
.then((res) => {
|
||||
const data = res.data.data || []
|
||||
setTemplates(data)
|
||||
setForm((prev) => ({ ...prev, template_id: data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') }))
|
||||
setForm((prev) => {
|
||||
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
||||
return applyTemplateDefaults({ ...prev, template_id: templateID })
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
@@ -190,14 +193,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
LXC 容器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
KVM 虚拟机
|
||||
@@ -213,7 +216,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
) : (
|
||||
<select
|
||||
value={form.template_id}
|
||||
onChange={(event) => setForm({ ...form, template_id: event.target.value })}
|
||||
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
|
||||
className={inputClass}
|
||||
>
|
||||
{templates.map((template) => (
|
||||
@@ -223,6 +226,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
</Field>
|
||||
|
||||
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
@@ -324,7 +328,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||
SSH: {sshPortPreview} -> 22
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
@@ -435,7 +439,10 @@ function NumberInput({
|
||||
|
||||
function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
|
||||
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
|
||||
const minVCPU = form.virtualization === 'kvm' ? 1 : 0.25
|
||||
const windows = isWindowsTemplate(form.template_id)
|
||||
const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25)
|
||||
const minRAMMB = windows ? 2048 : 128
|
||||
const minDiskGB = windows ? 30 : 1
|
||||
|
||||
if (!Number.isFinite(form.vcpu)) {
|
||||
errors.vcpu = '请输入 vCPU'
|
||||
@@ -449,16 +456,16 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
||||
|
||||
if (!Number.isFinite(form.ram_mb)) {
|
||||
errors.ram_mb = '请输入内存'
|
||||
} else if (form.ram_mb < 128) {
|
||||
errors.ram_mb = '不能小于 128 MB'
|
||||
} else if (form.ram_mb < minRAMMB) {
|
||||
errors.ram_mb = `不能小于 ${minRAMMB} MB`
|
||||
} else if (maxRAMMB && form.ram_mb > maxRAMMB) {
|
||||
errors.ram_mb = `不能大于 ${maxRAMMB} MB`
|
||||
}
|
||||
|
||||
if (!Number.isFinite(form.disk_gb)) {
|
||||
errors.disk_gb = '请输入磁盘'
|
||||
} else if (form.disk_gb < 1) {
|
||||
errors.disk_gb = '不能小于 1 GB'
|
||||
} else if (form.disk_gb < minDiskGB) {
|
||||
errors.disk_gb = `不能小于 ${minDiskGB} GB`
|
||||
} else if (maxDiskGB && form.disk_gb > maxDiskGB) {
|
||||
errors.disk_gb = `不能大于 ${maxDiskGB} GB`
|
||||
}
|
||||
@@ -467,15 +474,31 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
||||
}
|
||||
|
||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||
const normalized = applyTemplateDefaults(form)
|
||||
return {
|
||||
...normalized,
|
||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||
ram_mb: Math.round(normalized.ram_mb),
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
||||
}
|
||||
}
|
||||
|
||||
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
||||
if (!isWindowsTemplate(form.template_id)) return form
|
||||
return {
|
||||
...form,
|
||||
vcpu: form.virtualization === 'kvm' ? Math.round(form.vcpu) : normalizeLXCvCPU(form.vcpu),
|
||||
ram_mb: Math.round(form.ram_mb),
|
||||
disk_gb: Math.round(form.disk_gb),
|
||||
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
|
||||
virtualization: 'kvm',
|
||||
vcpu: Math.max(2, Math.round(Number.isFinite(form.vcpu) ? form.vcpu : 2)),
|
||||
ram_mb: Math.max(2048, Math.round(Number.isFinite(form.ram_mb) ? form.ram_mb : 2048)),
|
||||
disk_gb: Math.max(30, Math.round(Number.isFinite(form.disk_gb) ? form.disk_gb : 30)),
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsTemplate(templateID: string) {
|
||||
return templateID.toLowerCase().includes('windows')
|
||||
}
|
||||
|
||||
function normalizeLXCvCPU(value: number) {
|
||||
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
|
||||
return Number(rounded.toFixed(2))
|
||||
|
||||
@@ -27,6 +27,23 @@ interface SidebarProps {
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function GitHubIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M512 42.666667A464.64 464.64 0 0 0 42.666667 502.186667 460.373333 460.373333 0 0 0 363.52 938.666667c23.466667 4.266667 32-9.813333 32-22.186667v-78.08c-130.56 27.733333-158.293333-61.44-158.293333-61.44a122.026667 122.026667 0 0 0-52.053334-67.413333c-42.666667-28.16 3.413333-27.733333 3.413334-27.733334a98.56 98.56 0 0 1 71.68 47.36 101.12 101.12 0 0 0 136.533333 37.973334 99.413333 99.413333 0 0 1 29.866667-61.44c-104.106667-11.52-213.333333-50.773333-213.333334-226.986667a177.066667 177.066667 0 0 1 47.36-124.16 161.28 161.28 0 0 1 4.693334-121.173333s39.68-12.373333 128 46.933333a455.68 455.68 0 0 1 234.666666 0c89.6-59.306667 128-46.933333 128-46.933333a161.28 161.28 0 0 1 4.693334 121.173333A177.066667 177.066667 0 0 1 810.666667 477.866667c0 176.64-110.08 215.466667-213.333334 226.986666a106.666667 106.666667 0 0 1 32 85.333334v125.866666c0 14.933333 8.533333 26.88 32 22.186667A460.8 460.8 0 0 0 981.333333 502.186667 464.64 464.64 0 0 0 512 42.666667"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -239,9 +256,29 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
{version && (
|
||||
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
|
||||
{collapsed ? (
|
||||
<span title={`v${version}`}>v{version.split('.').slice(0, 2).join('.')}</span>
|
||||
<a
|
||||
href="https://github.com/MengMengCode/CLICD"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`CLICD v${version}`}
|
||||
className="inline-flex items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
<span>v{version}</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<a
|
||||
href="https://github.com/MengMengCode/CLICD"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="CLICD"
|
||||
className="inline-flex min-w-0 items-center gap-1 rounded text-gray-500 transition-colors hover:text-gray-950 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
<GitHubIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">CLICD</span>
|
||||
</a>
|
||||
<span className="shrink-0">v{version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||
import RFB from '@novnc/novnc'
|
||||
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||
|
||||
interface WebVNCViewerProps {
|
||||
containerName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<RFB | null>(null)
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const cleanup = () => {
|
||||
if (rfbRef.current) {
|
||||
rfbRef.current.disconnect()
|
||||
rfbRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
const target = screenRef.current
|
||||
if (!target) return
|
||||
|
||||
cleanup()
|
||||
target.innerHTML = ''
|
||||
setStatus('connecting')
|
||||
setErrorMsg('')
|
||||
|
||||
let ticket = ''
|
||||
try {
|
||||
const response = await createVNCTicket(containerName)
|
||||
ticket = response.data.data?.ticket || ''
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setStatus('error')
|
||||
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
if (!ticket) {
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
|
||||
rfb.scaleViewport = true
|
||||
rfb.resizeSession = false
|
||||
rfb.focusOnClick = true
|
||||
rfb.qualityLevel = 6
|
||||
rfb.compressionLevel = 2
|
||||
rfb.background = '#050505'
|
||||
rfb.addEventListener('connect', () => {
|
||||
setStatus('connected')
|
||||
})
|
||||
rfb.addEventListener('disconnect', (event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
|
||||
setStatus((current) => current === 'error' ? current : 'disconnected')
|
||||
if (detail && detail.clean === false) {
|
||||
setErrorMsg('WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用')
|
||||
}
|
||||
})
|
||||
rfb.addEventListener('securityfailure', () => {
|
||||
setStatus('error')
|
||||
setErrorMsg('VNC 安全协商失败')
|
||||
})
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
setStatus('error')
|
||||
setErrorMsg('当前 VNC 控制台要求密码,暂不支持自动输入')
|
||||
})
|
||||
rfbRef.current = rfb
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC 初始化失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(connect, 100)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
cleanup()
|
||||
}
|
||||
}, [containerName])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => rfbRef.current?.sendCtrlAltDel()}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
|
||||
title="发送 Ctrl+Alt+Del"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
|
||||
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||
{status === 'connecting' ? '正在连接 KVM VNC 控制台...' : (errorMsg || 'WebVNC 已断开')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
declare module '@novnc/novnc' {
|
||||
export default class RFB extends EventTarget {
|
||||
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
|
||||
scaleViewport: boolean
|
||||
resizeSession: boolean
|
||||
focusOnClick: boolean
|
||||
viewOnly: boolean
|
||||
qualityLevel: number
|
||||
compressionLevel: number
|
||||
background: string
|
||||
disconnect(): void
|
||||
sendCtrlAltDel(): void
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
Clock,
|
||||
Copy,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Key,
|
||||
Maximize2,
|
||||
MemoryStick,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
Network,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -65,6 +69,7 @@ import {
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import WebSSHViewer from '../components/WebSSHViewer'
|
||||
import WebVNCViewer from '../components/WebVNCViewer'
|
||||
import { RingStat } from '../components/RingStats'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
import ResourceStatsPanel, {
|
||||
@@ -115,6 +120,9 @@ export default function ContainerDetail() {
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [taskStatus, setTaskStatus] = useState('') // current task type for this container
|
||||
const [showSSH, setShowSSH] = useState(false)
|
||||
const [showVNC, setShowVNC] = useState(false)
|
||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||
const [showNat, setShowNat] = useState(false)
|
||||
const [showNatAdd, setShowNatAdd] = useState(false)
|
||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||
@@ -180,15 +188,18 @@ export default function ContainerDetail() {
|
||||
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
||||
if (!containerIdentifier || !currentContainer) return
|
||||
|
||||
const memoryPct = currentContainer.ram_mb > 0
|
||||
? (nextUsage.memory_usage_bytes / (currentContainer.ram_mb * 1024 * 1024)) * 100
|
||||
const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0
|
||||
? nextUsage.memory_total_bytes
|
||||
: currentContainer.ram_mb * 1024 * 1024
|
||||
const memoryPct = memoryTotalBytes > 0
|
||||
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
|
||||
: 0
|
||||
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
|
||||
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
|
||||
|
||||
const point: MetricPoint = {
|
||||
ts: Date.now(),
|
||||
cpu: clamp(nextUsage.cpu_usage_pct || 0),
|
||||
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
||||
memory: clamp(memoryPct),
|
||||
network: networkBps,
|
||||
diskIO: diskIOBps,
|
||||
@@ -227,6 +238,28 @@ export default function ContainerDetail() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [fetchContainer])
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setVncFullscreen(document.fullscreenElement === vncFullscreenRef.current)
|
||||
}
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const toggleVNCFullscreen = async () => {
|
||||
const target = vncFullscreenRef.current
|
||||
if (!target) return
|
||||
try {
|
||||
if (document.fullscreenElement === target) {
|
||||
await document.exitFullscreen()
|
||||
} else {
|
||||
await target.requestFullscreen()
|
||||
}
|
||||
} catch {
|
||||
setVncFullscreen((value) => !value)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsage()
|
||||
const timer = window.setInterval(fetchUsage, 5000)
|
||||
@@ -267,8 +300,17 @@ export default function ContainerDetail() {
|
||||
start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...',
|
||||
}
|
||||
|
||||
const ensureSubUserCanOperate = async () => {
|
||||
if (isSubUser && container?.policy_blocked) {
|
||||
await dialog.alert('策略临时封禁', container.policy_blocked_reason || '虚拟机被策略临时封禁,暂不能执行操作。')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
setActionLoading(action)
|
||||
try {
|
||||
switch (action) {
|
||||
@@ -278,6 +320,7 @@ export default function ContainerDetail() {
|
||||
case 'stop':
|
||||
await stopContainer(containerIdentifier)
|
||||
setShowSSH(false)
|
||||
setShowVNC(false)
|
||||
break
|
||||
case 'restart':
|
||||
await restartContainer(containerIdentifier)
|
||||
@@ -390,6 +433,7 @@ export default function ContainerDetail() {
|
||||
await reinstallContainer(containerIdentifier, selectedTemplate)
|
||||
setShowReinstall(false)
|
||||
setShowSSH(false)
|
||||
setShowVNC(false)
|
||||
await fetchContainer()
|
||||
} catch (err) {
|
||||
console.error('Reinstall failed:', err)
|
||||
@@ -428,11 +472,13 @@ export default function ContainerDetail() {
|
||||
}
|
||||
|
||||
const openAddMapping = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
setDraft(emptyDraft)
|
||||
setShowNat(true)
|
||||
}
|
||||
|
||||
const openEditMapping = (pm: PortMapping, index: number) => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
if (isSubUser) {
|
||||
// Sub-user: only edit container_port in a simple modal
|
||||
setDraft({
|
||||
@@ -455,6 +501,7 @@ export default function ContainerDetail() {
|
||||
|
||||
const submitMapping = async (): Promise<boolean> => {
|
||||
if (!containerIdentifier) return false
|
||||
if (!(await ensureSubUserCanOperate())) return false
|
||||
if (draft.index === null && container) {
|
||||
const currentCount = container.port_mappings?.length || 0
|
||||
const limit = container.port_mapping_limit || Math.max(currentCount, 2)
|
||||
@@ -507,6 +554,7 @@ export default function ContainerDetail() {
|
||||
|
||||
const removeMapping = async (index: number) => {
|
||||
if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
try {
|
||||
await deletePortMapping(containerIdentifier, index)
|
||||
await fetchContainer()
|
||||
@@ -519,6 +567,7 @@ export default function ContainerDetail() {
|
||||
|
||||
const handleCreateSnapshot = async () => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||
return
|
||||
@@ -543,6 +592,7 @@ export default function ContainerDetail() {
|
||||
}
|
||||
|
||||
const openSnapshotSchedule = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
setSnapshotScheduleDraft({
|
||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||
time: snapshotSchedule?.time || '03:00',
|
||||
@@ -552,6 +602,7 @@ export default function ContainerDetail() {
|
||||
|
||||
const saveSnapshotSchedule = async (enabled: boolean) => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
const intervalHours = snapshotScheduleDraft.intervalHours
|
||||
const scheduleTime = snapshotScheduleDraft.time || '03:00'
|
||||
if (enabled && intervalHours < 24) {
|
||||
@@ -591,6 +642,7 @@ export default function ContainerDetail() {
|
||||
|
||||
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
|
||||
setSnapshotBusy(snapshot.id)
|
||||
try {
|
||||
@@ -606,6 +658,7 @@ export default function ContainerDetail() {
|
||||
|
||||
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
|
||||
setSnapshotBusy(snapshot.id)
|
||||
try {
|
||||
@@ -643,17 +696,27 @@ export default function ContainerDetail() {
|
||||
}
|
||||
|
||||
const isRunning = container.status === 'running'
|
||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||
const isWindows = container.template?.includes('windows')
|
||||
const canOpenVNC = isKVM && isRunning
|
||||
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked
|
||||
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
||||
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
|
||||
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
|
||||
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22
|
||||
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
|
||||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
|
||||
)
|
||||
const filtered = filterHistory(history, range)
|
||||
const cpuPct = clamp(usage?.cpu_usage_pct || 0)
|
||||
const ramPct = container.ram_mb > 0 ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0
|
||||
const cpuPct = clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1)))
|
||||
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||
? usage.memory_total_bytes
|
||||
: container.ram_mb * 1024 * 1024
|
||||
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0
|
||||
const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0
|
||||
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
|
||||
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
|
||||
@@ -662,7 +725,7 @@ export default function ContainerDetail() {
|
||||
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
|
||||
const mappingCount = container.port_mappings?.length || 0
|
||||
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
|
||||
const canAddMapping = isSubUser ? mappingCount < mappingLimit : true
|
||||
const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true
|
||||
const managementUrl = subUser?.access_code
|
||||
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
||||
: ''
|
||||
@@ -670,7 +733,7 @@ export default function ContainerDetail() {
|
||||
{
|
||||
title: 'CPU 使用率',
|
||||
icon: <Cpu className="w-5 h-5" />,
|
||||
current: usage?.cpu_usage_pct || 0,
|
||||
current: clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))),
|
||||
points: toChartPoints(filtered, 'cpu'),
|
||||
max: 100,
|
||||
formatValue: formatPercent,
|
||||
@@ -683,7 +746,7 @@ export default function ContainerDetail() {
|
||||
points: toChartPoints(filtered, 'memory'),
|
||||
max: 100,
|
||||
formatValue: formatPercent,
|
||||
detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${container.ram_mb} MB`,
|
||||
detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${formatBytes(ramTotalBytes)}`,
|
||||
},
|
||||
{
|
||||
title: '网络流量',
|
||||
@@ -732,31 +795,40 @@ export default function ContainerDetail() {
|
||||
<InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
|
||||
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
||||
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
||||
<InfoTag color="violet">{publicHost}:{container.ssh_port}</InfoTag>
|
||||
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
|
||||
{isPolicyBlocked && <InfoTag color="red">策略封禁</InfoTag>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
||||
{!isRunning ? (
|
||||
<ActionButton dark disabled={!!taskStatus || isExpired} onClick={() => handleAction('start')}>
|
||||
<ActionButton dark disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('start')}>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
{isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
|
||||
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
|
||||
</ActionButton>
|
||||
) : (
|
||||
<>
|
||||
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('stop')}>
|
||||
<ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('stop')}>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
{isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
|
||||
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
|
||||
</ActionButton>
|
||||
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('restart')}>
|
||||
<ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('restart')}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
|
||||
</ActionButton>
|
||||
<ActionButton dark onClick={() => setShowSSH(true)}>
|
||||
<TerminalSquare className="w-3.5 h-3.5" />
|
||||
WebSSH
|
||||
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
|
||||
</ActionButton>
|
||||
{!isWindows && (
|
||||
<ActionButton dark disabled={isSubUserPolicyBlocked} onClick={() => setShowSSH(true)}>
|
||||
<TerminalSquare className="w-3.5 h-3.5" />
|
||||
WebSSH
|
||||
</ActionButton>
|
||||
)}
|
||||
{isKVM && (
|
||||
<ActionButton dark disabled={!canOpenVNC || isSubUserPolicyBlocked} onClick={() => setShowVNC(true)}>
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
WebVNC
|
||||
</ActionButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isSubUser && (
|
||||
@@ -766,12 +838,12 @@ export default function ContainerDetail() {
|
||||
</ActionButton>
|
||||
)}
|
||||
<>
|
||||
<ActionButton onClick={() => setShowNat(true)}>
|
||||
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
NAT 管理
|
||||
</ActionButton>
|
||||
</>
|
||||
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}>
|
||||
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
快照
|
||||
</ActionButton>
|
||||
@@ -791,32 +863,75 @@ export default function ContainerDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSubUserPolicyBlocked && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">虚拟机被策略临时封禁</div>
|
||||
<div className="mt-1 text-xs text-red-600">{policyBlockedText}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<Panel title="连接信息">
|
||||
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
|
||||
<PlainRow label="用户名" value="root" mono />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-gray-500">SSH 密码</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span
|
||||
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
title={showPassword ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
{showPassword ? (container.ssh_password || '-') : '••••••••'}
|
||||
</span>
|
||||
{container.ssh_password && (
|
||||
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
{isSubUserPolicyBlocked ? (
|
||||
<div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
虚拟机被策略临时封禁,连接信息暂不可用。
|
||||
</div>
|
||||
) : isWindows ? (
|
||||
<>
|
||||
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
|
||||
<PlainRow label="用户名" value="Administrator" mono />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-gray-500">管理员密码</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span
|
||||
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
title={showPassword ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
|
||||
</span>
|
||||
{container.ssh_password && (
|
||||
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{container.vnc_port > 0 && (
|
||||
<PlainRow label="VNC 端口" value={`127.0.0.1:${container.vnc_port}`} mono />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
|
||||
<PlainRow label="用户名" value="root" mono />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-gray-500">SSH 密码</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span
|
||||
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
title={showPassword ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
|
||||
</span>
|
||||
{container.ssh_password && (
|
||||
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isSubUser && (
|
||||
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
|
||||
<Key className="w-3 h-3" />
|
||||
重置 SSH 密码
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isSubUser && (
|
||||
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
|
||||
<Key className="w-3 h-3" />
|
||||
重置 SSH 密码
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -871,7 +986,7 @@ export default function ContainerDetail() {
|
||||
<RingStat
|
||||
value={ramPct}
|
||||
label="内存"
|
||||
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(container.ram_mb * 1024 * 1024)}`}
|
||||
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(ramTotalBytes)}`}
|
||||
/>
|
||||
<RingStat
|
||||
value={loadPct}
|
||||
@@ -980,6 +1095,38 @@ export default function ContainerDetail() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showVNC && (
|
||||
<Modal
|
||||
title={`WebVNC - ${container.name}`}
|
||||
onClose={() => setShowVNC(false)}
|
||||
wide
|
||||
flush
|
||||
extra={
|
||||
<button
|
||||
onClick={toggleVNCFullscreen}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-gray-600 hover:text-black hover:bg-gray-100 rounded"
|
||||
title={vncFullscreen ? '退出全屏' : '全屏显示'}
|
||||
>
|
||||
{vncFullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
||||
{vncFullscreen ? '退出全屏' : '全屏'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={vncFullscreenRef}
|
||||
className={`bg-white ${vncFullscreen ? 'fixed inset-0 z-[70] p-3' : 'h-[calc(92vh-112px)] min-h-[420px] p-5'}`}
|
||||
>
|
||||
<div className="h-full">
|
||||
{canOpenVNC ? (
|
||||
<WebVNCViewer containerName={container.name} onClose={() => setShowVNC(false)} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center bg-gray-950 text-gray-400 rounded-md">VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSnapshots && (
|
||||
<Modal
|
||||
title="快照"
|
||||
@@ -1397,13 +1544,14 @@ function StatusBadge({ running }: { running: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate'; children: ReactNode }) {
|
||||
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate' | 'red'; children: ReactNode }) {
|
||||
const classes = {
|
||||
blue: 'bg-blue-50 text-blue-700 border-blue-100',
|
||||
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
||||
amber: 'bg-amber-50 text-amber-700 border-amber-100',
|
||||
violet: 'bg-violet-50 text-violet-700 border-violet-100',
|
||||
slate: 'bg-slate-50 text-slate-700 border-slate-100',
|
||||
red: 'bg-red-50 text-red-700 border-red-100',
|
||||
}
|
||||
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
|
||||
}
|
||||
@@ -1573,7 +1721,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{mappings.map((pm, index) => {
|
||||
const isSSH = pm.description === 'SSH' || pm.container_port === 22
|
||||
const isSSH = pm.description === 'SSH' || pm.container_port === 22 || pm.description === 'RDP' || pm.container_port === 3389
|
||||
return (
|
||||
<tr key={`${pm.host_port}-${pm.container_port}-${index}`}>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
@@ -1619,7 +1767,7 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Modal({ title, children, onClose, wide = false, extra }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode }) {
|
||||
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}>
|
||||
@@ -1632,7 +1780,7 @@ function Modal({ title, children, onClose, wide = false, extra }: { title: strin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 overflow-y-auto">{children}</div>
|
||||
<div className={flush ? "overflow-hidden" : "p-5 overflow-y-auto"}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1817,5 +1965,6 @@ function getTemplateIcon(id: string): ReactNode {
|
||||
if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg>
|
||||
if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg>
|
||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -392,11 +392,18 @@ export default function Containers() {
|
||||
const isRunning = container.status === 'running'
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||
const isPlaceholder = !!container.isPlaceholder
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
const usage = usageByName[container.name]
|
||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||
|
||||
const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0
|
||||
const ramPct = isRunning && container.ram_mb > 0
|
||||
? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100)
|
||||
const cpuPct = isRunning
|
||||
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
|
||||
: 0
|
||||
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||
? usage.memory_total_bytes
|
||||
: container.ram_mb * 1024 * 1024
|
||||
const ramPct = isRunning && ramTotalBytes > 0
|
||||
? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100)
|
||||
: 0
|
||||
const diskPct = container.disk_gb > 0
|
||||
? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100)
|
||||
@@ -430,7 +437,7 @@ export default function Containers() {
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top">
|
||||
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} />
|
||||
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -574,8 +581,17 @@ type DisplayContainer = Container & {
|
||||
createTask?: Task
|
||||
}
|
||||
|
||||
function StatusBadge({ running, task, placeholder }: { running: boolean; task?: Task; placeholder?: boolean }) {
|
||||
function StatusBadge({ running, task, placeholder, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
||||
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
|
||||
if (policyBlocked) {
|
||||
return (
|
||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
策略封禁
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (task?.status === 'failed') {
|
||||
return (
|
||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||
@@ -803,6 +819,7 @@ function getSystemFilterValue(template: string) {
|
||||
if (normalized.startsWith('archlinux')) return 'archlinux'
|
||||
if (normalized.startsWith('fedora')) return 'fedora'
|
||||
if (normalized.startsWith('rockylinux')) return 'rockylinux'
|
||||
if (normalized.startsWith('windows')) return 'windows'
|
||||
return normalized || 'unknown'
|
||||
}
|
||||
|
||||
@@ -815,6 +832,7 @@ function getSystemFilterLabel(system: string) {
|
||||
archlinux: 'Arch Linux',
|
||||
fedora: 'Fedora',
|
||||
rockylinux: 'Rocky Linux',
|
||||
windows: 'Windows',
|
||||
unknown: '未知系统',
|
||||
}
|
||||
return labels[system] || system
|
||||
@@ -944,6 +962,7 @@ function getTemplateName(id: string) {
|
||||
'kvm-debian-bookworm': 'Debian 12',
|
||||
'kvm-debian-bullseye': 'Debian 11',
|
||||
'kvm-rockylinux-9': 'Rocky 9',
|
||||
'kvm-windows-10': 'Windows 10',
|
||||
}
|
||||
return map[id] || id
|
||||
}
|
||||
@@ -958,6 +977,7 @@ function getTemplateIcon(id: string): ReactNode {
|
||||
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
|
||||
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
|
||||
export default function ImageManagement() {
|
||||
const dialog = useDialog()
|
||||
const [images, setImages] = useState<ImageInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
@@ -43,15 +45,14 @@ export default function ImageManagement() {
|
||||
await downloadImage(templateId)
|
||||
await fetchImages()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '下载失败'
|
||||
setError(msg)
|
||||
setError(apiErrorMessage(err, '下载失败'))
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (templateId: string) => {
|
||||
if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return
|
||||
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
|
||||
setActionLoading(templateId)
|
||||
setError('')
|
||||
try {
|
||||
@@ -207,6 +208,7 @@ function ImageTable({
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
|
||||
<p className="text-[11px] text-gray-400">{img.description}</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -316,6 +318,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function isWindowsImage(img: ImageInfo) {
|
||||
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
|
||||
}
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-5 h-5'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
@@ -326,9 +332,15 @@ function getTemplateIcon(id: string): ReactNode {
|
||||
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
|
||||
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||
return null
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string) {
|
||||
const error = err as { response?: { data?: { message?: string } }; message?: string }
|
||||
return error.response?.data?.message || error.message || fallback
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes <= 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
|
||||
+156
-13
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { getSecurityAlerts, SecurityAlert } from '../services/api'
|
||||
import { FileText, Power, RefreshCw, X } from 'lucide-react'
|
||||
import { getSecurityAlerts, getSecurityLogs, getSecuritySettings, SecurityAlert, SecurityLog, updateSecuritySettings } from '../services/api'
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
port_scan: '端口扫描',
|
||||
@@ -23,12 +23,18 @@ const severityLabels: Record<string, string> = {
|
||||
|
||||
export default function Security() {
|
||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
|
||||
const [autoShutdown, setAutoShutdown] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingSettings, setSavingSettings] = useState(false)
|
||||
const [logAlert, setLogAlert] = useState<SecurityAlert | null>(null)
|
||||
const [logs, setLogs] = useState<SecurityLog[]>([])
|
||||
const [logsLoading, setLogsLoading] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const alertRes = await getSecurityAlerts()
|
||||
const [alertRes, settingsRes] = await Promise.all([getSecurityAlerts(), getSecuritySettings()])
|
||||
if (alertRes.data.data) setAlerts(alertRes.data.data)
|
||||
if (settingsRes.data.data) setAutoShutdown(settingsRes.data.data.auto_shutdown)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
@@ -42,6 +48,36 @@ export default function Security() {
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
const handleAutoShutdownChange = async () => {
|
||||
const next = !autoShutdown
|
||||
setAutoShutdown(next)
|
||||
setSavingSettings(true)
|
||||
try {
|
||||
const res = await updateSecuritySettings({ auto_shutdown: next })
|
||||
if (res.data.data) setAutoShutdown(res.data.data.auto_shutdown)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setAutoShutdown(!next)
|
||||
} finally {
|
||||
setSavingSettings(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openLogs = async (alert: SecurityAlert) => {
|
||||
setLogAlert(alert)
|
||||
setLogs([])
|
||||
setLogsLoading(true)
|
||||
try {
|
||||
const res = await getSecurityLogs(alert.container_name)
|
||||
setLogs(filterRelatedLogs(res.data.data || [], alert))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setLogs([])
|
||||
} finally {
|
||||
setLogsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -52,15 +88,33 @@ export default function Security() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-semibold text-black">安全告警</h1>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoShutdown}
|
||||
onClick={handleAutoShutdownChange}
|
||||
disabled={savingSettings}
|
||||
title="告警自动关机"
|
||||
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition-colors disabled:opacity-60 ${
|
||||
autoShutdown
|
||||
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
<span>{autoShutdown ? '自动关机已开' : '自动关机已关'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
@@ -93,12 +147,24 @@ export default function Security() {
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip || '-'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">
|
||||
{formatTarget(alert)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td>
|
||||
<td className="px-4 py-2.5 text-gray-600 min-w-[260px]">{alert.detail}</td>
|
||||
<td className="px-4 py-2.5 text-gray-600 min-w-[300px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1">{alert.detail}</span>
|
||||
<button
|
||||
onClick={() => openLogs(alert)}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
|
||||
title="查看相关记录"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
查看
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -106,6 +172,69 @@ export default function Security() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{logAlert && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-4xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-black">相关连接记录</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{logAlert.container_name} · {typeLabels[logAlert.type] || logAlert.type} · {formatTarget(logAlert)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setLogAlert(null)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[70vh] overflow-auto">
|
||||
{logAlert.log_line && (
|
||||
<div className="border-b border-gray-100 bg-gray-50 px-4 py-3">
|
||||
<div className="mb-1 text-xs font-medium text-gray-600">告警原始记录</div>
|
||||
<pre className="whitespace-pre-wrap break-all rounded border border-gray-200 bg-white p-3 text-xs text-gray-700">{logAlert.log_line}</pre>
|
||||
</div>
|
||||
)}
|
||||
{logsLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-500">正在加载连接记录...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-500">
|
||||
暂无可用连接记录。历史告警对应的 conntrack 记录可能已经过期。
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
|
||||
<th className="px-4 py-2.5">协议</th>
|
||||
<th className="px-4 py-2.5">状态</th>
|
||||
<th className="px-4 py-2.5">源地址</th>
|
||||
<th className="px-4 py-2.5">目标地址</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{logs.map((log, index) => (
|
||||
<tr key={`${log.src_ip}-${log.src_port}-${log.dst_ip}-${log.dst_port}-${index}`}>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.protocol || '-'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.state || '-'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
|
||||
{formatEndpoint(log.src_ip, log.src_port)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
|
||||
{formatEndpoint(log.dst_ip, log.dst_port)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -129,3 +258,17 @@ function formatTarget(alert: SecurityAlert): string {
|
||||
if (!alert.target_ip) return '-'
|
||||
return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip
|
||||
}
|
||||
|
||||
function filterRelatedLogs(logs: SecurityLog[], alert: SecurityAlert): SecurityLog[] {
|
||||
return logs.filter((log) => {
|
||||
if (alert.source_ip && log.src_ip !== alert.source_ip) return false
|
||||
if (alert.target_ip && alert.target_ip !== '*' && log.dst_ip !== alert.target_ip) return false
|
||||
if (alert.target_port > 0 && log.dst_port !== alert.target_port) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function formatEndpoint(ip: string, port: number): string {
|
||||
if (!ip) return '-'
|
||||
return port > 0 ? `${ip}:${port}` : ip
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ export interface Container {
|
||||
snapshot_schedule_last_run: string
|
||||
snapshot_schedule_next_run: string
|
||||
snapshot_schedule_created_by: string
|
||||
policy_blocked?: boolean
|
||||
policy_blocked_reason?: string
|
||||
policy_blocked_at?: string
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
@@ -159,6 +162,7 @@ export interface HostInfo {
|
||||
|
||||
export interface ContainerUsage {
|
||||
memory_usage_bytes: number
|
||||
memory_total_bytes?: number
|
||||
cpu_usage_usec: number
|
||||
cpu_usage_pct: number
|
||||
disk_usage_bytes: number
|
||||
@@ -173,6 +177,7 @@ export interface ContainerUsage {
|
||||
load1: number
|
||||
load5: number
|
||||
load15: number
|
||||
guest_metrics?: boolean
|
||||
}
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
@@ -353,6 +358,7 @@ export interface ImageInfo {
|
||||
enabled: boolean
|
||||
downloading: boolean
|
||||
size_bytes: number
|
||||
manual_path?: string
|
||||
}
|
||||
|
||||
export const getImages = () =>
|
||||
@@ -440,6 +446,13 @@ export const getWebSSHUrl = (containerName: string) => {
|
||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||
}
|
||||
|
||||
export const getWebVNCUrl = (containerName: string, ticket?: string) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams({ container: containerName })
|
||||
if (ticket) params.set('ticket', ticket)
|
||||
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
||||
}
|
||||
|
||||
// Task Queue
|
||||
export interface Task {
|
||||
id: string
|
||||
@@ -514,6 +527,19 @@ export interface SecuritySummary {
|
||||
low: number
|
||||
}
|
||||
|
||||
export interface SecuritySettings {
|
||||
auto_shutdown: boolean
|
||||
}
|
||||
|
||||
export interface SecurityLog {
|
||||
src_ip: string
|
||||
dst_ip: string
|
||||
src_port: number
|
||||
dst_port: number
|
||||
protocol: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export const getSecurityAlerts = () =>
|
||||
api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
|
||||
|
||||
@@ -521,14 +547,23 @@ export const checkContainerSecurity = (containerName: string) =>
|
||||
api.post<APIResponse>('/security/check', { container_name: containerName })
|
||||
|
||||
export const getSecurityLogs = (containerName: string) =>
|
||||
api.get<APIResponse>('/security/logs', { params: { container: containerName } })
|
||||
api.get<APIResponse<SecurityLog[]>>('/security/logs', { params: { container: containerName } })
|
||||
|
||||
export const getSecuritySummary = () =>
|
||||
api.get<APIResponse<SecuritySummary>>('/security/summary')
|
||||
|
||||
export const getSecuritySettings = () =>
|
||||
api.get<APIResponse<SecuritySettings>>('/security/settings')
|
||||
|
||||
export const updateSecuritySettings = (data: SecuritySettings) =>
|
||||
api.put<APIResponse<SecuritySettings>>('/security/settings', data)
|
||||
|
||||
export const createWebSSHTicket = (containerName: string) =>
|
||||
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
||||
|
||||
export const createVNCTicket = (containerName: string) =>
|
||||
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
|
||||
|
||||
// Version
|
||||
export const getVersion = () =>
|
||||
api.get<APIResponse<{ version: string }>>('/version')
|
||||
|
||||
@@ -14,5 +14,6 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
target: 'es2022',
|
||||
}
|
||||
})
|
||||
|
||||
+235
-16
@@ -138,6 +138,157 @@ remove_lxc_container_dir() {
|
||||
log "Removed $container_dir"
|
||||
}
|
||||
|
||||
remove_kvm_domain() {
|
||||
domain="$1"
|
||||
case "$domain" in
|
||||
vm-[0-9]*)
|
||||
;;
|
||||
*)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
suffix="${domain#vm-}"
|
||||
case "$suffix" in
|
||||
""|*[!0-9]*)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
if [ ! -d "/var/lib/clicd/kvm/instances/$domain" ] &&
|
||||
! virsh dumpxml "$domain" 2>/dev/null | grep -q '/var/lib/clicd/kvm/'; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "Removing KVM domain $domain..."
|
||||
virsh destroy "$domain" >/dev/null 2>&1 || true
|
||||
virsh undefine "$domain" --remove-all-storage --nvram >/dev/null 2>&1 ||
|
||||
virsh undefine "$domain" --nvram >/dev/null 2>&1 ||
|
||||
virsh undefine "$domain" >/dev/null 2>&1 ||
|
||||
true
|
||||
}
|
||||
|
||||
destroy_clicd_kvm_domains() {
|
||||
if ! has_cmd virsh; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "Destroying CLICD KVM domains..."
|
||||
virsh list --all --name 2>/dev/null | while IFS= read -r domain; do
|
||||
[ -n "$domain" ] || continue
|
||||
remove_kvm_domain "$domain"
|
||||
done
|
||||
}
|
||||
|
||||
delete_iptables_lines() {
|
||||
table="$1"
|
||||
chain="$2"
|
||||
pattern="$3"
|
||||
if ! has_cmd iptables; then
|
||||
return
|
||||
fi
|
||||
|
||||
while :; do
|
||||
line="$(iptables -t "$table" -L "$chain" -n --line-numbers 2>/dev/null | awk -v pat="$pattern" '$0 ~ pat {print $1; exit}')"
|
||||
[ -n "$line" ] || break
|
||||
iptables -t "$table" -D "$chain" "$line" >/dev/null 2>&1 || break
|
||||
done
|
||||
}
|
||||
|
||||
delete_iptables_rule() {
|
||||
table="$1"
|
||||
shift
|
||||
if ! has_cmd iptables; then
|
||||
return
|
||||
fi
|
||||
|
||||
while iptables -t "$table" -D "$@" >/dev/null 2>&1; do
|
||||
:
|
||||
done
|
||||
}
|
||||
|
||||
delete_filter_rule() {
|
||||
if ! has_cmd iptables; then
|
||||
return
|
||||
fi
|
||||
|
||||
while iptables -D "$@" >/dev/null 2>&1; do
|
||||
:
|
||||
done
|
||||
}
|
||||
|
||||
delete_ip6tables_bridge_rules() {
|
||||
if ! has_cmd ip6tables; then
|
||||
return
|
||||
fi
|
||||
|
||||
for bridge in lxcbr0 virbr0; do
|
||||
while :; do
|
||||
rule="$(ip6tables -S FORWARD 2>/dev/null | grep -- "$bridge" | sed 's/^-A /-D /' | head -n 1)"
|
||||
[ -n "$rule" ] || break
|
||||
# shellcheck disable=SC2086
|
||||
ip6tables $rule >/dev/null 2>&1 || break
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_clicd_networking() {
|
||||
log "Cleaning CLICD firewall and bridge rules..."
|
||||
delete_iptables_lines nat PREROUTING 'clicd-'
|
||||
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
|
||||
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
|
||||
|
||||
for bridge in lxcbr0 virbr0; do
|
||||
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
|
||||
delete_filter_rule FORWARD -o "$bridge" -j ACCEPT
|
||||
delete_filter_rule FORWARD -i "$bridge" -o "$bridge" -j ACCEPT
|
||||
done
|
||||
delete_ip6tables_bridge_rules
|
||||
}
|
||||
|
||||
remove_clicd_host_hooks() {
|
||||
if has_cmd systemctl; then
|
||||
systemctl stop clicd-kvm-ipv6.service >/dev/null 2>&1 || true
|
||||
systemctl disable clicd-kvm-ipv6.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
if has_cmd rc-service; then
|
||||
rc-service clicd-kvm-ipv6 stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
if has_cmd rc-update; then
|
||||
rc-update del clicd-kvm-ipv6 default >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
remove_path /usr/local/sbin/clicd-kvm-ipv6-init
|
||||
remove_path /etc/systemd/system/clicd-kvm-ipv6.service
|
||||
remove_path /etc/local.d/clicd-kvm-ipv6.start
|
||||
remove_path /etc/network/if-up.d/clicd-kvm-ipv6
|
||||
}
|
||||
|
||||
remove_clicd_quota_records() {
|
||||
for file in /etc/projects /etc/projid; do
|
||||
[ -f "$file" ] || continue
|
||||
tmp="${file}.clicd-clean.$$"
|
||||
grep -v 'clicd-' "$file" > "$tmp" || true
|
||||
cat "$tmp" > "$file"
|
||||
rm -f "$tmp"
|
||||
log "Cleaned CLICD quota records from $file"
|
||||
done
|
||||
}
|
||||
|
||||
remove_clicd_tmp_files() {
|
||||
for path in /tmp/clicd-* /tmp/clicd.*; do
|
||||
[ -e "$path" ] || [ -L "$path" ] || continue
|
||||
rm -rf "$path"
|
||||
log "Removed $path"
|
||||
done
|
||||
}
|
||||
|
||||
remove_clicd_swapfile() {
|
||||
if [ ! -e /swapfile ]; then
|
||||
return
|
||||
fi
|
||||
swapoff /swapfile >/dev/null 2>&1 || true
|
||||
remove_path /swapfile
|
||||
}
|
||||
|
||||
uninstall_clicd() {
|
||||
log "Uninstalling CLICD..."
|
||||
|
||||
@@ -158,6 +309,10 @@ uninstall_clicd() {
|
||||
[ -d "$container_dir" ] || continue
|
||||
remove_lxc_container_dir "$container_dir"
|
||||
done
|
||||
destroy_clicd_kvm_domains
|
||||
cleanup_clicd_networking
|
||||
remove_clicd_host_hooks
|
||||
remove_clicd_quota_records
|
||||
|
||||
remove_path /etc/systemd/system/clicd.service
|
||||
remove_path /etc/init.d/clicd
|
||||
@@ -168,7 +323,13 @@ uninstall_clicd() {
|
||||
remove_path /root/.clicd
|
||||
unmount_path_tree /var/lib/lxc
|
||||
remove_path /var/lib/lxc
|
||||
unmount_path_tree /var/lib/clicd
|
||||
remove_path /var/lib/clicd
|
||||
remove_path /var/cache/lxc
|
||||
remove_path /var/cache/clicd
|
||||
remove_path /root/clicd-backups
|
||||
remove_clicd_tmp_files
|
||||
remove_clicd_swapfile
|
||||
|
||||
if has_cmd systemctl; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
@@ -182,7 +343,9 @@ uninstall_clicd() {
|
||||
echo "====================================="
|
||||
echo " CLICD Uninstalled"
|
||||
echo "====================================="
|
||||
echo " Removed service, binary, config, containers, and LXC image cache."
|
||||
echo " Removed service, binary, SQLite/config data, LXC containers,"
|
||||
echo " CLICD KVM domains, VM images, image caches, firewall rules,"
|
||||
echo " host hooks, quota records, temp files, backups, and swapfile."
|
||||
echo "====================================="
|
||||
}
|
||||
|
||||
@@ -220,9 +383,16 @@ install_apk() {
|
||||
bridge-utils \
|
||||
iproute2 \
|
||||
iptables \
|
||||
dnsmasq
|
||||
dnsmasq \
|
||||
dbus \
|
||||
qemu-system-x86_64 \
|
||||
qemu-img \
|
||||
libvirt \
|
||||
libvirt-daemon \
|
||||
libvirt-client \
|
||||
libvirt-qemu
|
||||
|
||||
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs; do
|
||||
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do
|
||||
apk add --no-cache "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg"
|
||||
done
|
||||
}
|
||||
@@ -249,7 +419,16 @@ install_apt() {
|
||||
quota \
|
||||
e2fsprogs \
|
||||
xfsprogs \
|
||||
dnsmasq-base
|
||||
dnsmasq-base \
|
||||
qemu-kvm \
|
||||
qemu-utils \
|
||||
libvirt-daemon-system \
|
||||
libvirt-clients \
|
||||
cloud-image-utils \
|
||||
genisoimage \
|
||||
xorriso \
|
||||
virtinst \
|
||||
ovmf
|
||||
}
|
||||
|
||||
enable_el_repos() {
|
||||
@@ -285,9 +464,19 @@ install_dnf() {
|
||||
quota \
|
||||
e2fsprogs \
|
||||
xfsprogs \
|
||||
dnsmasq
|
||||
dnsmasq \
|
||||
qemu-kvm \
|
||||
qemu-img \
|
||||
libvirt \
|
||||
libvirt-daemon-kvm \
|
||||
libvirt-client \
|
||||
virt-install \
|
||||
cloud-utils \
|
||||
genisoimage
|
||||
|
||||
dnf install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs"
|
||||
for pkg in lxcfs xorriso edk2-ovmf; do
|
||||
dnf install -y "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg"
|
||||
done
|
||||
}
|
||||
|
||||
install_yum() {
|
||||
@@ -310,9 +499,19 @@ install_yum() {
|
||||
quota \
|
||||
e2fsprogs \
|
||||
xfsprogs \
|
||||
dnsmasq
|
||||
dnsmasq \
|
||||
qemu-kvm \
|
||||
qemu-img \
|
||||
libvirt \
|
||||
libvirt-daemon-kvm \
|
||||
libvirt-client \
|
||||
virt-install \
|
||||
cloud-utils \
|
||||
genisoimage
|
||||
|
||||
yum install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs"
|
||||
for pkg in lxcfs xorriso edk2-ovmf; do
|
||||
yum install -y "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg"
|
||||
done
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
@@ -350,6 +549,15 @@ install_dependencies() {
|
||||
has_cmd lxc-create || die "lxc-create is still missing after dependency installation."
|
||||
has_cmd iptables || die "iptables is still missing after dependency installation."
|
||||
has_cmd ip || die "iproute2/ip command is still missing after dependency installation."
|
||||
has_cmd virsh || die "virsh is still missing after dependency installation."
|
||||
has_cmd qemu-img || die "qemu-img is still missing after dependency installation."
|
||||
has_cmd cloud-localds || die "cloud-localds is still missing after dependency installation."
|
||||
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
|
||||
die "one of genisoimage, mkisofs, or xorriso is required for Windows KVM setup."
|
||||
fi
|
||||
if [ ! -e /dev/kvm ]; then
|
||||
log "Warning: /dev/kvm was not found. KVM VMs require hardware virtualization or nested virtualization."
|
||||
fi
|
||||
}
|
||||
|
||||
configure_kernel_networking() {
|
||||
@@ -365,13 +573,17 @@ EOF
|
||||
sysctl --system >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
setup_lxc_services() {
|
||||
log "Configuring LXC services..."
|
||||
setup_runtime_services() {
|
||||
log "Configuring LXC and KVM services..."
|
||||
|
||||
if is_systemd; then
|
||||
systemctl enable --now lxcfs >/dev/null 2>&1 || true
|
||||
systemctl enable --now lxc-net >/dev/null 2>&1 || true
|
||||
systemctl enable --now lxc >/dev/null 2>&1 || true
|
||||
systemctl enable --now libvirtd >/dev/null 2>&1 || true
|
||||
systemctl enable --now virtqemud >/dev/null 2>&1 || true
|
||||
systemctl enable --now virtqemud.socket >/dev/null 2>&1 || true
|
||||
systemctl enable --now virtlogd.socket >/dev/null 2>&1 || true
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -382,6 +594,12 @@ setup_lxc_services() {
|
||||
rc-service lxc start >/dev/null 2>&1 || true
|
||||
rc-update add lxcfs default >/dev/null 2>&1 || true
|
||||
rc-service lxcfs start >/dev/null 2>&1 || true
|
||||
rc-update add dbus default >/dev/null 2>&1 || true
|
||||
rc-service dbus start >/dev/null 2>&1 || true
|
||||
rc-update add libvirtd default >/dev/null 2>&1 || true
|
||||
rc-service libvirtd start >/dev/null 2>&1 || true
|
||||
rc-update add virtlogd default >/dev/null 2>&1 || true
|
||||
rc-service virtlogd start >/dev/null 2>&1 || true
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -466,8 +684,9 @@ install_binary() {
|
||||
install_systemd_service() {
|
||||
cat > /etc/systemd/system/clicd.service << 'EOF'
|
||||
[Unit]
|
||||
Description=CLICD - LXC Container Manager
|
||||
After=network.target lxc.service
|
||||
Description=CLICD - LXC/KVM Container Manager
|
||||
After=network.target lxc.service libvirtd.service virtqemud.service
|
||||
Wants=libvirtd.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -490,7 +709,7 @@ install_openrc_service() {
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name="CLICD"
|
||||
description="CLICD - LXC Container Manager"
|
||||
description="CLICD - LXC/KVM Container Manager"
|
||||
command="/usr/local/bin/clicd"
|
||||
command_args="server"
|
||||
command_background=true
|
||||
@@ -500,7 +719,7 @@ error_log="/var/log/clicd.err"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after lxc
|
||||
after lxc libvirtd
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -544,13 +763,13 @@ print_summary() {
|
||||
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
|
||||
fi
|
||||
echo ""
|
||||
echo "If no password is shown, this server already had /root/.clicd/config.json."
|
||||
echo "If no password is shown, this server already had /root/.clicd/config.db."
|
||||
echo "The existing admin password cannot be recovered from the bcrypt hash."
|
||||
}
|
||||
|
||||
install_dependencies
|
||||
configure_kernel_networking
|
||||
setup_lxc_services
|
||||
setup_runtime_services
|
||||
setup_subids
|
||||
try_enable_project_quota
|
||||
download_release_if_needed
|
||||
|
||||
Reference in New Issue
Block a user