Compare commits

...

21 Commits

Author SHA1 Message Date
MengMengCode f2fa2449e9 release: v1.1.26 2026-07-22 17:02:23 +08:00
MengMengCode 53d56be8f9 fix #33 2026-07-22 16:57:44 +08:00
MengMengCode ec38ab9136 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-07-21 21:26:09 +08:00
MengMengCode fdcd7df9e9 Update API endpoint. 2026-07-21 21:26:04 +08:00
Meng Meng d6d46296fe Merge pull request #32 from MengMengCode/dependabot/npm_and_yarn/frontend/axios-1.18.0
build(deps): bump axios from 1.17.0 to 1.18.0 in /frontend
2026-07-21 15:13:02 +08:00
dependabot[bot] 3f44c7565f build(deps): bump axios from 1.17.0 to 1.18.0 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 20:30:35 +00:00
Meng Meng 61d842d94c Fix star history image link in README.md
Updated the star history image link in the README.
2026-07-20 12:52:40 +08:00
Meng Meng ca303d33f6 Replace star history section with new embed link 2026-07-19 20:08:13 +08:00
MengMengCode 6bdeafccf2 修复剩余 CodeQL 高危告警 2026-07-18 21:31:31 +08:00
MengMengCode 04cefe0cf1 release: v1.1.25 2026-07-18 21:18:54 +08:00
MengMengCode f28117bc5e 修复了一些已知问题 2026-07-18 21:16:29 +08:00
MengMengCode 2324494dd7 优化了机器创建流程 2026-07-18 20:45:23 +08:00
MengMengCode ebba97f1d6 · 修复了一些已知问题
· 增加了局域网DHCP IP分配适配
· 完善了多盘兼容支持 #17
2026-07-18 19:44:32 +08:00
MengMengCode 3dabd93d2f Fix some problem. 2026-07-17 00:44:47 +08:00
MengMengCode 8bad52bd9e FIX ##30 2026-07-17 00:27:31 +08:00
MengMengCode d05ca8cc4c release: v1.1.24 2026-07-16 23:28:28 +08:00
MengMengCode 48fa14f8a7 FIX #18 2026-07-16 23:27:26 +08:00
MengMengCode 5c6d6eafc9 Support Debian 13.
#29
and fix useage chart data display problem, storage on database instead of localstorage.
2026-07-16 21:24:28 +08:00
MengMengCode 2ecdb5c26f fix Dependabot alerts 2026-07-16 21:01:27 +08:00
MengMengCode ae02241370 KVM的端口转发_ 失效修复 2026-07-16 20:57:07 +08:00
MengMengCode fdd83977fc 支持限制用户可选择的系统 2026-07-16 20:47:26 +08:00
57 changed files with 6584 additions and 767 deletions
+1
View File
@@ -71,3 +71,4 @@ deploy.ps1
backend/clicd
api.md
deploy-arm.ps1
deploy-dhcp.ps1
+1 -7
View File
@@ -119,10 +119,4 @@ This open-source software is intended solely for educational purposes, specifica
## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
</picture>
</a>
[![MengMengCode/CLICD Star History](http://mengmeng.meteor-history.com/api/embed/MengMengCode/CLICD.svg?sig=YT8i1bxihL6_GcFAa0CWRbQb35-B0XXyh-ZAxIsmV0U&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=3)](https://meteor-history.com)
+242
View File
@@ -0,0 +1,242 @@
package api
import (
"encoding/json"
"fmt"
"math"
"strconv"
"sync"
"time"
"clicd/internal/config"
)
type ContainerMetricPoint struct {
TS int64 `json:"ts"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Network float64 `json:"network"`
NetworkRx float64 `json:"network_rx"`
NetworkTx float64 `json:"network_tx"`
DiskIO float64 `json:"disk_io"`
DiskRead float64 `json:"disk_read"`
DiskWrite float64 `json:"disk_write"`
}
var containerMetricSamplerOnce sync.Once
var containerMetricMu sync.RWMutex
var containerMetricHistory = map[string][]ContainerMetricPoint{}
var containerMetricInFlight sync.Map
const (
containerMetricSampleInterval = 30 * time.Second
containerMetricSampleTimeout = 20 * time.Second
containerMetricConcurrency = 4
)
func StartContainerMetricSampler() {
containerMetricSamplerOnce.Do(func() {
go func() {
sampleAllContainerMetrics()
ticker := time.NewTicker(containerMetricSampleInterval)
defer ticker.Stop()
for range ticker.C {
sampleAllContainerMetrics()
}
}()
})
}
func sampleAllContainerMetrics() {
containers, _ := listByRuntime()
sem := make(chan struct{}, containerMetricConcurrency)
var wg sync.WaitGroup
for _, c := range containers {
c := c
if c.Status != "running" {
continue
}
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
sampleContainerMetricWithTimeout(c)
}()
}
wg.Wait()
pruneContainerMetricHistory()
}
func sampleContainerMetricWithTimeout(c config.Container) {
key := containerMetricKey(c)
if key == "" {
return
}
if _, loaded := containerMetricInFlight.LoadOrStore(key, struct{}{}); loaded {
return
}
done := make(chan struct{}, 1)
go func() {
defer containerMetricInFlight.Delete(key)
if usage, err := usageByRuntime(c.ID); err == nil {
appendContainerMetricPoint(c, usage)
}
done <- struct{}{}
}()
select {
case <-done:
case <-time.After(containerMetricSampleTimeout):
}
}
func appendContainerMetricPoint(c config.Container, usage map[string]interface{}) {
key := containerMetricKey(c)
if key == "" {
return
}
memoryTotal := numberFromUsage(usage, "memory_total_bytes")
if memoryTotal <= 0 {
memoryTotal = float64(c.RAMMB) * 1024 * 1024
}
memoryPct := 0.0
if memoryTotal > 0 {
memoryPct = clampPercent(numberFromUsage(usage, "memory_usage_bytes") / memoryTotal * 100)
}
vcpu := c.VCPU
if vcpu <= 0 {
vcpu = 1
}
cpuPct := clampPercent(numberFromUsage(usage, "cpu_usage_pct") / vcpu)
networkRx := positiveNumberFromUsage(usage, "network_rx_bps")
networkTx := positiveNumberFromUsage(usage, "network_tx_bps")
diskRead := positiveNumberFromUsage(usage, "disk_read_bps")
diskWrite := positiveNumberFromUsage(usage, "disk_write_bps")
point := ContainerMetricPoint{
TS: time.Now().UnixMilli(),
CPU: cpuPct,
Memory: memoryPct,
NetworkRx: networkRx,
NetworkTx: networkTx,
Network: networkRx + networkTx,
DiskRead: diskRead,
DiskWrite: diskWrite,
DiskIO: diskRead + diskWrite,
}
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
containerMetricMu.Lock()
defer containerMetricMu.Unlock()
history := containerMetricHistory[key]
keepFrom := 0
for keepFrom < len(history) && history[keepFrom].TS < cutoff {
keepFrom++
}
if keepFrom > 0 {
copy(history, history[keepFrom:])
history = history[:len(history)-keepFrom]
}
containerMetricHistory[key] = append(history, point)
}
func getContainerMetricHistory(c *config.Container) []ContainerMetricPoint {
if c == nil {
return nil
}
key := containerMetricKey(*c)
containerMetricMu.RLock()
defer containerMetricMu.RUnlock()
history := containerMetricHistory[key]
result := make([]ContainerMetricPoint, len(history))
copy(result, history)
return result
}
func pruneContainerMetricHistory() {
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
valid := map[string]bool{}
if config.AppConfig != nil {
for _, c := range config.AppConfig.Containers {
valid[containerMetricKey(c)] = true
}
}
containerMetricMu.Lock()
defer containerMetricMu.Unlock()
for key, history := range containerMetricHistory {
if !valid[key] {
delete(containerMetricHistory, key)
continue
}
keepFrom := 0
for keepFrom < len(history) && history[keepFrom].TS < cutoff {
keepFrom++
}
if keepFrom > 0 {
copy(history, history[keepFrom:])
containerMetricHistory[key] = history[:len(history)-keepFrom]
}
}
}
func containerMetricKey(c config.Container) string {
if c.UUID != "" {
return "uuid:" + c.UUID
}
if c.ID > 0 {
return fmt.Sprintf("id:%d", c.ID)
}
if c.Name != "" {
return "name:" + c.Name
}
return ""
}
func numberFromUsage(usage map[string]interface{}, key string) float64 {
value, ok := usage[key]
if !ok || value == nil {
return 0
}
switch v := value.(type) {
case float64:
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0
}
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
case int32:
return float64(v)
case uint:
return float64(v)
case uint64:
return float64(v)
case uint32:
return float64(v)
case json.Number:
n, _ := v.Float64()
return n
case string:
n, _ := strconv.ParseFloat(v, 64)
return n
default:
return 0
}
}
func positiveNumberFromUsage(usage map[string]interface{}, key string) float64 {
value := numberFromUsage(usage, key)
if value < 0 {
return 0
}
return value
}
+37
View File
@@ -132,6 +132,11 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
return
}
getUsage(w, r, id)
case action == "history" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getContainerMetricHistory(c)})
case action == "traffic" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
@@ -167,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
return
}
assignIPv6(w, r, id)
case action == "public-ipv4" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:network") {
return
}
updatePublicIPv4(w, r, id)
case action == "ipv6-addresses" && r.Method == http.MethodPut:
if !requireScope(w, r, "ipv6:assign") {
return
}
updateIPv6Addresses(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost:
@@ -240,6 +255,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
if ids, err := normalizeAllowedImageIDs(cfg.AllowedImageIDs); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
} else {
cfg.AllowedImageIDs = ids
}
if cfg.VCPU <= 0 {
cfg.VCPU = 1
}
@@ -288,6 +309,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if err := validateCreateStoragePool(&cfg); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if err := validateCreateSSHAuth(cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
@@ -655,6 +680,18 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
}
// HandleHostHistory returns host resource samples collected by the server.
func HandleHostHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "host:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getHostMetricHistory()})
}
func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c != nil && lxc.IsExpired(*c) {
+141 -16
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"net"
"net/http"
"os"
@@ -22,12 +23,13 @@ import (
)
type HostInfo struct {
CPU CpuInfo `json:"cpu"`
RAM MemoryInfo `json:"ram"`
Disk DiskInfo `json:"disk"`
Network NetworkInfo `json:"network"`
DiskIO DiskIOInfo `json:"disk_io"`
Load LoadInfo `json:"load"`
CPU CpuInfo `json:"cpu"`
RAM MemoryInfo `json:"ram"`
Disk DiskInfo `json:"disk"`
Network NetworkInfo `json:"network"`
DiskIO DiskIOInfo `json:"disk_io"`
Load LoadInfo `json:"load"`
Runtime HostRuntimeProbe `json:"runtime"`
}
type HostProbeReport struct {
@@ -216,14 +218,35 @@ type DiskIOInfo struct {
WriteBps float64 `json:"write_bps"`
}
type HostMetricPoint struct {
TS int64 `json:"ts"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Network float64 `json:"network"`
NetworkRx float64 `json:"network_rx"`
NetworkTx float64 `json:"network_tx"`
DiskIO float64 `json:"disk_io"`
DiskRead float64 `json:"disk_read"`
DiskWrite float64 `json:"disk_write"`
DiskUsagePct float64 `json:"disk_usage_pct"`
}
var hostCPUMu sync.Mutex
var lastHostCPU cpuTimes
var hostIOMu sync.Mutex
var lastHostIO hostIOSample
var hostMetricSamplerOnce sync.Once
var hostMetricMu sync.RWMutex
var hostMetricHistory []HostMetricPoint
var egressIPv4Mu sync.Mutex
var cachedEgressIPv4 lxc.PublicIPInfo
var cachedEgressIPv4At time.Time
const (
hostMetricSampleInterval = 30 * time.Second
hostMetricRetention = 7 * 24 * time.Hour
)
type cpuTimes struct {
Total uint64
Idle uint64
@@ -238,6 +261,10 @@ type hostIOSample struct {
}
func getHostInfo() HostInfo {
return getHostInfoWithNetworkDetails(true)
}
func getHostInfoWithNetworkDetails(includeDetails bool) HostInfo {
info := HostInfo{
CPU: CpuInfo{Cores: runtime.NumCPU()},
}
@@ -245,11 +272,107 @@ func getHostInfo() HostInfo {
info.RAM = getMemoryInfo()
info.Disk = getDiskInfo()
info.CPU.Usage = getCPUUsage()
info.Network, info.DiskIO = getHostRates()
info.Network, info.DiskIO = getHostRates(includeDetails)
info.Load = getLoadInfo()
info.Runtime = detectRuntimeProbeQuick()
return info
}
func detectRuntimeProbeQuick() HostRuntimeProbe {
devKVM := fileExists("/dev/kvm")
nested, detail := detectNestedVirtualization()
lxcOK := commandExists("lxc-create")
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
kvmOK := kvmSupportedArch && devKVM && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
probe := HostRuntimeProbe{
LXCAvailable: lxcOK,
KVMAvailable: kvmOK,
DevKVM: devKVM,
NestedVirtualization: nested,
NestedDetail: detail,
SupportMode: "unsupported",
}
if probe.KVMAvailable {
probe.SupportMode = "kvm_lxc"
} else if probe.LXCAvailable {
probe.SupportMode = "lxc_only"
}
return probe
}
func StartHostMetricSampler() {
hostMetricSamplerOnce.Do(func() {
appendHostMetricPoint(getHostInfoWithNetworkDetails(false))
go func() {
ticker := time.NewTicker(hostMetricSampleInterval)
defer ticker.Stop()
for range ticker.C {
appendHostMetricPoint(getHostInfoWithNetworkDetails(false))
}
}()
})
}
func appendHostMetricPoint(info HostInfo) {
memoryPct := 0.0
if info.RAM.TotalMB > 0 {
memoryPct = clampPercent(float64(info.RAM.UsedMB) / float64(info.RAM.TotalMB) * 100)
}
diskUsagePct := 0.0
if info.Disk.TotalGB > 0 {
diskUsagePct = clampPercent(info.Disk.UsedGB / info.Disk.TotalGB * 100)
}
point := HostMetricPoint{
TS: time.Now().UnixMilli(),
CPU: clampPercent(info.CPU.Usage),
Memory: memoryPct,
NetworkRx: info.Network.RXBps,
NetworkTx: info.Network.TXBps,
Network: info.Network.RXBps + info.Network.TXBps,
DiskRead: info.DiskIO.ReadBps,
DiskWrite: info.DiskIO.WriteBps,
DiskIO: info.DiskIO.ReadBps + info.DiskIO.WriteBps,
DiskUsagePct: diskUsagePct,
}
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
hostMetricMu.Lock()
defer hostMetricMu.Unlock()
keepFrom := 0
for keepFrom < len(hostMetricHistory) && hostMetricHistory[keepFrom].TS < cutoff {
keepFrom++
}
if keepFrom > 0 {
copy(hostMetricHistory, hostMetricHistory[keepFrom:])
hostMetricHistory = hostMetricHistory[:len(hostMetricHistory)-keepFrom]
}
hostMetricHistory = append(hostMetricHistory, point)
}
func getHostMetricHistory() []HostMetricPoint {
hostMetricMu.RLock()
defer hostMetricMu.RUnlock()
result := make([]HostMetricPoint, len(hostMetricHistory))
copy(result, hostMetricHistory)
return result
}
func clampPercent(value float64) float64 {
if value < 0 || !isFiniteFloat(value) {
return 0
}
if value > 100 {
return 100
}
return value
}
func isFiniteFloat(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func getMemoryInfo() MemoryInfo {
f, err := os.Open("/proc/meminfo")
if err != nil {
@@ -395,20 +518,22 @@ func parseSizeGBf(s string) (float64, error) {
return val, err
}
func getHostRates() (NetworkInfo, DiskIOInfo) {
func getHostRates(includeDetails bool) (NetworkInfo, DiskIOInfo) {
rx, tx := readHostNetworkBytes()
readBytes, writeBytes := readHostDiskBytes()
now := unixNano()
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
publicIPv4 := detectDisplayPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
if includeDetails {
publicIPv4 := detectDisplayPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
}
}
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
+255 -4
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
@@ -41,6 +42,9 @@ type ImageInfo struct {
var imageDownloadsMu sync.Mutex
var imageDownloads = map[string]*imageDownloadStatus{}
var lxcImageCacheMu sync.Mutex
var lxcImageDownloadMu sync.Mutex
var lxcImageDownloadActive bool
type imageDownloadStatus struct {
Downloading bool
@@ -136,6 +140,22 @@ func isImageDownloadActive(id string) bool {
return st != nil && st.Downloading
}
func beginLXCImageDownload() bool {
lxcImageDownloadMu.Lock()
defer lxcImageDownloadMu.Unlock()
if lxcImageDownloadActive {
return false
}
lxcImageDownloadActive = true
return true
}
func endLXCImageDownload() {
lxcImageDownloadMu.Lock()
lxcImageDownloadActive = false
lxcImageDownloadMu.Unlock()
}
func lxcImageDownloadTempName(id string) string {
return fmt.Sprintf("clicd-img-dl-%s", id)
}
@@ -307,7 +327,6 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
image := kvm.FindImage(req.TemplateID)
@@ -319,6 +338,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
return
}
if _, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
@@ -359,6 +382,29 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
return
}
if !beginLXCImageDownload() {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Another LXC image download is active"})
return
}
lxcDownloadHandedOff := false
defer func() {
if !lxcDownloadHandedOff {
endLXCImageDownload()
}
}()
imagePool, err := config.SelectStoragePoolForContent(
config.StorageContentImages,
"",
dirSizeBytes("/var/cache/lxc/download")+1024*1024*1024,
)
if err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if err := ensureLXCImageCachePool(*imagePool); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
// Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
@@ -375,6 +421,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
}
go func(tmpl lxc.Template) {
defer endLXCImageDownload()
// Download via lxc-create with a temp container, then destroy it.
tmpName := lxcImageDownloadTempName(tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--",
@@ -386,7 +433,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
st.Stage = "lxc-create"
})
cmd := exec.CommandContext(ctx, "lxc-create", args...)
output, err := cmd.CombinedOutput()
output, err := runLXCImageDownloadCommand(cmd, tmpl.ID)
// Clean up the temp container unconditionally.
cleanupLXCImageDownloadTemp(tmpl.ID)
@@ -403,10 +450,154 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
ensureImageEnabled(tmpl.ID)
finishImageDownload(tmpl.ID, nil)
}(*tmpl)
lxcDownloadHandedOff = true
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
}
type lxcImageDownloadCommandResult struct {
output []byte
err error
}
func runLXCImageDownloadCommand(cmd *exec.Cmd, templateID string) ([]byte, error) {
startedAt := time.Now()
done := make(chan lxcImageDownloadCommandResult, 1)
go func() {
output, err := cmd.CombinedOutput()
done <- lxcImageDownloadCommandResult{output: output, err: err}
}()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
var lastBytes int64
for {
select {
case result := <-done:
return result.output, result.err
case <-ticker.C:
downloadedBytes := newestLXCRootfsDownloadSize(startedAt)
if downloadedBytes <= 0 || downloadedBytes == lastBytes {
continue
}
lastBytes = downloadedBytes
updateImageDownload(templateID, func(st *imageDownloadStatus) {
st.Stage = "downloading"
st.DownloadedBytes = downloadedBytes
})
}
}
}
func newestLXCRootfsDownloadSize(startedAt time.Time) int64 {
matches, _ := filepath.Glob("/tmp/tmp.*/rootfs.tar.xz")
var newestTime time.Time
var newestSize int64
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || info.IsDir() || info.ModTime().Before(startedAt.Add(-5*time.Second)) {
continue
}
if info.ModTime().After(newestTime) {
newestTime = info.ModTime()
newestSize = info.Size()
}
}
return newestSize
}
func ensureLXCImageCachePool(pool config.StoragePool) error {
lxcImageCacheMu.Lock()
defer lxcImageCacheMu.Unlock()
cachePath := "/var/cache/lxc/download"
targetPath := filepath.Join(pool.Path, "images", "lxc")
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return err
}
if err := os.MkdirAll(targetAbs, 0755); err != nil {
return fmt.Errorf("failed to create LXC image storage: %v", err)
}
info, err := os.Lstat(cachePath)
if os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
return err
}
return os.Symlink(targetAbs, cachePath)
}
if err != nil {
return err
}
sourcePath := cachePath
linked := info.Mode()&os.ModeSymlink != 0
if linked {
sourcePath, err = filepath.EvalSymlinks(cachePath)
if err != nil {
return fmt.Errorf("failed to resolve LXC image cache: %v", err)
}
}
sourceAbs, err := filepath.Abs(sourcePath)
if err != nil {
return err
}
if sourceAbs == targetAbs {
return nil
}
if strings.HasPrefix(targetAbs, sourceAbs+string(os.PathSeparator)) || strings.HasPrefix(sourceAbs, targetAbs+string(os.PathSeparator)) {
return fmt.Errorf("LXC image cache source and target must not be nested")
}
if !info.IsDir() && !linked {
return fmt.Errorf("LXC image cache is not a directory: %s", cachePath)
}
if output, err := exec.Command("cp", "-a", sourceAbs+string(os.PathSeparator)+".", targetAbs+string(os.PathSeparator)).CombinedOutput(); err != nil {
return fmt.Errorf("failed to migrate LXC image cache: %v, output: %s", err, strings.TrimSpace(string(output)))
}
tempLink := fmt.Sprintf("%s.clicd-new-%d", cachePath, time.Now().UnixNano())
if err := os.Symlink(targetAbs, tempLink); err != nil {
return err
}
if linked {
if err := os.Rename(tempLink, cachePath); err != nil {
_ = os.Remove(tempLink)
return fmt.Errorf("failed to switch LXC image cache: %v", err)
}
if isManagedLXCImageCachePath(sourceAbs) {
_ = os.RemoveAll(sourceAbs)
}
return nil
}
backupPath := fmt.Sprintf("%s.clicd-backup-%d", cachePath, time.Now().UnixNano())
if err := os.Rename(cachePath, backupPath); err != nil {
_ = os.Remove(tempLink)
return fmt.Errorf("failed to prepare LXC image cache migration: %v", err)
}
if err := os.Rename(tempLink, cachePath); err != nil {
_ = os.Rename(backupPath, cachePath)
_ = os.Remove(tempLink)
return fmt.Errorf("failed to activate LXC image storage: %v", err)
}
if err := os.RemoveAll(backupPath); err != nil {
return fmt.Errorf("LXC image cache migrated but old cache cleanup failed: %v", err)
}
return nil
}
func isManagedLXCImageCachePath(path string) bool {
path = filepath.Clean(path)
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
if path == filepath.Clean(filepath.Join(pool.Path, "images", "lxc")) {
return true
}
}
return path == filepath.Clean("/var/lib/clicd/images/lxc")
}
// HandleImageCancel cancels an in-progress image download.
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -541,6 +732,24 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet()
var subUser *config.SubUser
var targetContainer *config.Container
currentImageIDs := map[string]bool{}
if isSubUserRequest(r) {
subUser = subUserFromRequest(r)
if identifier := r.URL.Query().Get("container"); identifier != "" {
targetContainer = containerByIdentifier(identifier)
if targetContainer == nil || !isContainerAllowedForRequest(r, identifier) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
currentImageIDs[targetContainer.Template] = true
} else {
for _, id := range subUserCurrentImageIDs(subUser) {
currentImageIDs[id] = true
}
}
}
result := make([]map[string]string, 0)
if runtime == config.VirtualizationKVM {
@@ -549,7 +758,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
return
}
for _, t := range kvm.GetImages() {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
continue
}
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
@@ -558,7 +770,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
}
} else {
for _, t := range lxc.GetTemplates() {
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
continue
}
if downloaded := isImageDownloaded(t.Distro, t.Release, t.Arch); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
@@ -574,6 +789,42 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
}
func imageTemplateExists(templateID string) bool {
return lxc.FindTemplate(templateID) != nil || kvm.FindImage(templateID) != nil
}
func isImageDownloadedForRuntime(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM {
if !hostKVMAvailable() {
return false
}
image := kvm.FindImage(templateID)
if image == nil {
return false
}
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
return downloaded
}
tmpl := lxc.FindTemplate(templateID)
if tmpl == nil {
return false
}
return isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
}
func isTemplateAvailableForRequest(r *http.Request, c *config.Container, templateID string, runtime string) bool {
if isSubUserRequest(r) {
if !isTemplateAllowedForRequest(r, c, templateID) {
return false
}
if c != nil && c.Template == templateID {
return isImageDownloadedForRuntime(templateID, runtime)
}
}
return isImageEnabledAndDownloaded(templateID, runtime)
}
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM {
+55 -1
View File
@@ -1,6 +1,9 @@
package api
import "net/http"
import (
"encoding/json"
"net/http"
)
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -22,3 +25,54 @@ func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
}
type ipAssignmentRequest struct {
Mode string `json:"mode"`
Auto *bool `json:"auto,omitempty"`
Count int `json:"count,omitempty"`
Addresses []string `json:"addresses,omitempty"`
}
func (req ipAssignmentRequest) allocation() ([]string, int, bool) {
auto := req.Mode == "random" || req.Mode == "auto"
if req.Mode == "custom" {
auto = false
}
if req.Mode == "clear" || req.Mode == "none" {
return nil, 0, false
}
if req.Auto != nil {
auto = *req.Auto
}
return req.Addresses, req.Count, auto
}
func updatePublicIPv4(w http.ResponseWriter, r *http.Request, id int) {
var req ipAssignmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
addresses, count, auto := req.allocation()
c, err := updatePublicIPv4ByRuntime(id, addresses, count, auto)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Public IPv4 assignments updated", Data: c})
}
func updateIPv6Addresses(w http.ResponseWriter, r *http.Request, id int) {
var req ipAssignmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
addresses, count, auto := req.allocation()
c, err := updateIPv6ByRuntime(id, addresses, count, auto)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assignments updated", Data: c})
}
+104
View File
@@ -0,0 +1,104 @@
package api
import (
"fmt"
"time"
"clicd/internal/config"
"clicd/internal/kvm"
"clicd/internal/lxc"
)
// CaptureRuntimeRestoreState records which managed workloads are actually
// running before the CLICD service exits. On the next host boot, only those
// workloads are started again.
func CaptureRuntimeRestoreState() {
if config.AppConfig == nil {
return
}
lxcManager := lxc.NewManager()
kvmManager := kvm.NewManager()
changed := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
status, err := runtimeStatus(*c, lxcManager, kvmManager)
if err != nil {
fmt.Printf("Warning: failed to capture runtime state for %s: %v\n", c.Name, err)
continue
}
restore := status == "running"
if c.RestoreOnHostBoot != restore {
c.RestoreOnHostBoot = restore
changed = true
}
if status != "" && c.Status != status {
c.Status = status
changed = true
}
}
if changed {
if err := config.SaveConfig(); err != nil {
fmt.Printf("Warning: failed to save host boot restore state: %v\n", err)
}
}
}
func StartHostBootRestore() {
go RestoreHostBootState()
}
func RestoreHostBootState() {
if config.AppConfig == nil {
return
}
time.Sleep(2 * time.Second)
lxcManager := lxc.NewManager()
kvmManager := kvm.NewManager()
containers := append([]config.Container(nil), config.AppConfig.Containers...)
for _, c := range containers {
if !c.RestoreOnHostBoot {
continue
}
if c.PolicyBlocked {
fmt.Printf("Skipping host boot restore for %s: policy blocked\n", c.Name)
continue
}
if lxc.IsExpired(c) {
fmt.Printf("Skipping host boot restore for %s: expired at %s\n", c.Name, c.ExpiresAt)
continue
}
status, err := runtimeStatus(c, lxcManager, kvmManager)
if err == nil && status == "running" {
config.UpdateContainerStatusAndRestore(c.ID, "running", true)
if !c.IsKVM() {
_ = lxcManager.ApplyPortMappings(c.ID)
} else {
_ = lxc.NewManager().ApplyPortMappings(c.ID)
}
continue
}
fmt.Printf("Restoring workload after host boot: %s (ID=%d)\n", c.Name, c.ID)
if c.IsKVM() {
if err := kvmManager.StartContainer(c.ID); err != nil {
fmt.Printf("Warning: failed to restore KVM %s: %v\n", c.Name, err)
}
continue
}
if err := lxcManager.StartContainer(c.ID); err != nil {
fmt.Printf("Warning: failed to restore LXC %s: %v\n", c.Name, err)
}
}
lxc.EnsureAllRunningPortMappings()
}
func runtimeStatus(c config.Container, lxcManager *lxc.Manager, kvmManager *kvm.Manager) (string, error) {
if c.IsKVM() {
return kvmManager.GetContainerStatus(c.VirshName())
}
return lxcManager.GetContainerStatus(c.LxcName())
}
+42
View File
@@ -46,6 +46,19 @@ type ipv4Route struct {
Gateway string `json:"gateway,omitempty"`
}
type lanDHCPRoute struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
LXCName string `json:"lxc_name"`
Status string `json:"status"`
Address string `json:"address"`
Interface string `json:"interface"`
PrefixLen int `json:"prefix_len,omitempty"`
Gateway string `json:"gateway,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Mode string `json:"mode"`
}
type ipv6Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
@@ -60,10 +73,12 @@ type routingResponse struct {
NAT4 routeCapacity `json:"nat4"`
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
IPv4 routeCapacity `json:"ipv4"`
LANDHCP routeCapacity `json:"lan_dhcp"`
IPv6 routeCapacity `json:"ipv6"`
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
LANDHCPAssignments []lanDHCPRoute `json:"lan_dhcp_assignments"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
@@ -125,6 +140,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
ipv4Assignments := make([]ipv4Route, 0)
lanDHCPAssignments := make([]lanDHCPRoute, 0)
ipv6Assignments := make([]ipv6Route, 0)
nat4StartPort, nat4EndPort := config.NATPortRange()
@@ -164,6 +180,20 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
})
}
c.NormalizeNetworkAssignments()
if c.UsesLANIPv4() {
lanDHCPAssignments = append(lanDHCPAssignments, lanDHCPRoute{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: c.IP,
Interface: c.LANInterface,
PrefixLen: c.LANIPv4PrefixLen,
Gateway: c.LANIPv4Gateway,
MACAddress: c.MACAddress,
Mode: c.LANIPv4Mode,
})
}
for _, ip := range c.IPv6Addresses {
if ip.Address == "" {
continue
@@ -191,6 +221,12 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
})
sort.SliceStable(lanDHCPAssignments, func(i, j int) bool {
if lanDHCPAssignments[i].Interface == lanDHCPAssignments[j].Interface {
return lanDHCPAssignments[i].ContainerName < lanDHCPAssignments[j].ContainerName
}
return lanDHCPAssignments[i].Interface < lanDHCPAssignments[j].Interface
})
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
})
@@ -231,6 +267,11 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
Remaining: strconv.Itoa(ipv4Remaining),
Total: strconv.Itoa(ipv4Total),
},
LANDHCP: routeCapacity{
Used: len(lanDHCPAssignments),
Remaining: "DHCP",
Total: "DHCP",
},
IPv6: routeCapacity{
Used: len(ipv6Assignments),
Remaining: ipv6Remaining,
@@ -239,6 +280,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
HostPublicIPv4: hostPublicIPv4,
PublicIPv4Addresses: publicIPv4s,
IPv4Assignments: ipv4Assignments,
LANDHCPAssignments: lanDHCPAssignments,
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
+20 -4
View File
@@ -20,7 +20,7 @@ func runtimeFromRequest(value string) string {
}
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
return cfg.WantsNAT() || cfg.WantsLANIPv4() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
}
func runtimeFromTemplateID(templateID string) string {
@@ -115,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) {
return lxcManager.AssignIPv6(id)
}
func updatePublicIPv4ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
}
return lxcManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
}
func updateIPv6ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.UpdateIPv6Assignments(id, requested, count, auto)
}
return lxcManager.UpdateIPv6Assignments(id, requested, count, auto)
}
func usageByRuntime(id int) (map[string]interface{}, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
@@ -131,12 +147,12 @@ func trafficByRuntime(id int) map[string]interface{} {
return lxcManager.GetTrafficInfo(id)
}
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
}
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
}
func deleteSnapshotByRuntime(snapshotID string) error {
+33
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"time"
@@ -48,6 +49,38 @@ func HandleLanguage(w http.ResponseWriter, r *http.Request) {
}
}
// HandleTaskQueueSettings returns or updates the global task concurrency limit.
func HandleTaskQueueSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: globalQueue.Settings()})
case http.MethodPut, http.MethodPost:
var req struct {
Concurrency int `json:"concurrency"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.Concurrency < 1 || req.Concurrency > config.MaxTaskConcurrency {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "任务并发数必须在 1 到 16 之间"})
return
}
previous := config.AppConfig.TaskConcurrency
config.AppConfig.TaskConcurrency = req.Concurrency
if err := config.SaveConfig(); err != nil {
config.AppConfig.TaskConcurrency = previous
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存任务队列设置失败"})
return
}
globalQueue.SetConcurrency(req.Concurrency)
auditRequest(r, "settings.task_queue", "task_concurrency", fmt.Sprintf("concurrency=%d", req.Concurrency), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "任务队列设置已保存", Data: globalQueue.Settings()})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// RecordLoginLog adds a login attempt to the log (persisted to config)
func RecordLoginLog(username, ip, userAgent string, success bool) {
config.AddLoginLog(username, ip, userAgent, success)
+22 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"io"
"net/http"
"sort"
"strconv"
@@ -88,6 +89,20 @@ func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
user := requestUser(r)
var req struct {
StoragePoolID string `json:"storage_pool_id"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
}
req.StoragePoolID = strings.TrimSpace(req.StoragePoolID)
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, req.StoragePoolID, 0); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
if isSubUserRequest(r) {
c := config.FindContainer(containerID)
limit := config.ContainerSnapshotLimit(c)
@@ -96,7 +111,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
return
}
}
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0, req.StoragePoolID)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
@@ -159,6 +174,12 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
if req.Time == "" {
req.Time = "03:00"
}
if req.Enabled {
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, "", 0); err != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
return
}
}
user := requestUser(r)
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
if err != nil {
+498
View File
@@ -0,0 +1,498 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
pathpkg "path"
"path/filepath"
"strings"
"clicd/internal/config"
)
type storageInfoResponse struct {
Pools []storagePoolInfo `json:"pools"`
Disks []storageDiskInfo `json:"disks"`
ContentTypes []string `json:"content_types"`
}
type storagePoolInfo struct {
config.StoragePool
Available bool `json:"available"`
Exists bool `json:"exists"`
SizeBytes int64 `json:"size_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
ContentUsage []storageContentUsage `json:"content_usage"`
Error string `json:"error,omitempty"`
}
type storageContentUsage struct {
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
type storageDiskInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
FSType string `json:"fstype"`
MountPoint string `json:"mount_point"`
Model string `json:"model"`
SizeBytes int64 `json:"size_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
StoragePath string `json:"storage_path,omitempty"`
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
ContentUsage []storageContentUsage `json:"content_usage"`
}
func HandleStorage(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
case http.MethodPut:
var req struct {
Pools []config.StoragePool `json:"pools"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
pools, err := normalizeStoragePoolsRequest(req.Pools)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
for _, pool := range pools {
if err := os.MkdirAll(pool.Path, 0755); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: fmt.Sprintf("Failed to create %s: %v", pool.Path, err)})
return
}
}
config.AppConfig.StoragePools = pools
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save storage pools"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func buildStorageInfo() storageInfoResponse {
disks := detectStorageDisks()
pools := make([]storagePoolInfo, 0, len(config.AppConfig.StoragePools))
for _, pool := range config.AppConfig.StoragePools {
info := storagePoolInfo{StoragePool: pool}
if filepath.Clean(pool.MountPoint) == string(os.PathSeparator) {
_ = os.MkdirAll(pool.Path, 0755)
}
if st, err := os.Stat(pool.Path); err == nil && st.IsDir() {
info.Exists = true
} else if err != nil {
info.Error = err.Error()
}
detectedMountPoint := bestMountPointForPath(pool.Path, disks)
if info.MountPoint == "" {
info.MountPoint = detectedMountPoint
}
if detectedMountPoint != "" && filepath.Clean(info.MountPoint) == filepath.Clean(detectedMountPoint) {
info.Available = info.Exists
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(pool.Path)
info.ContentUsage, info.ClicdUsedBytes = contentUsageForPool(pool.Path)
} else if info.Error == "" {
info.Error = "storage disk is not mounted"
}
pools = append(pools, info)
}
for i := range disks {
for _, pool := range pools {
if pool.MountPoint != disks[i].MountPoint {
continue
}
disks[i].ClicdUsedBytes += pool.ClicdUsedBytes
disks[i].ContentUsage = mergeContentUsage(disks[i].ContentUsage, pool.ContentUsage)
if disks[i].StoragePoolID == "" {
disks[i].StoragePoolID = pool.ID
disks[i].StoragePath = pool.Path
}
}
}
return storageInfoResponse{
Pools: pools,
Disks: disks,
ContentTypes: []string{
config.StorageContentLXC,
config.StorageContentKVM,
config.StorageContentImages,
config.StorageContentSnapshots,
config.StorageContentBackups,
},
}
}
func normalizeStoragePoolsRequest(items []config.StoragePool) ([]config.StoragePool, error) {
return normalizeStoragePoolsRequestWithDisks(items, detectStorageDisks())
}
func normalizeStoragePoolsRequestWithDisks(items []config.StoragePool, disks []storageDiskInfo) ([]config.StoragePool, error) {
if len(items) == 0 {
return nil, fmt.Errorf("at least one mounted storage disk configuration must be retained")
}
result := make([]config.StoragePool, 0, len(items))
seen := map[string]bool{}
defaultSeen := map[string]bool{}
for _, item := range items {
disk, managedPath, err := storageDiskForPoolRequest(item, disks)
if err != nil {
return nil, err
}
id, name := storagePoolIdentity(disk)
if seen[id] {
return nil, fmt.Errorf("duplicate storage disk: %s", disk.MountPoint)
}
seen[id] = true
contentTypes := normalizeStorageContentTypes(item.ContentTypes)
defaultContents := normalizeStorageContentTypes(item.DefaultContents)
allowed := map[string]bool{}
for _, content := range contentTypes {
allowed[content] = true
}
defaults := make([]string, 0, len(defaultContents))
for _, content := range defaultContents {
if !allowed[content] {
continue
}
if defaultSeen[content] {
return nil, fmt.Errorf("only one default storage disk is allowed for %s", content)
}
defaultSeen[content] = true
defaults = append(defaults, content)
}
result = append(result, config.StoragePool{
ID: id,
Name: name,
Path: managedPath,
MountPoint: disk.MountPoint,
ContentTypes: contentTypes,
DefaultContents: defaults,
Enabled: item.Enabled,
})
}
return result, nil
}
func storageDiskForPoolRequest(item config.StoragePool, disks []storageDiskInfo) (storageDiskInfo, string, error) {
requestedMount := filepath.Clean(strings.TrimSpace(item.MountPoint))
if requestedMount == "." {
requestedMount = ""
}
requestedPath := filepath.Clean(strings.TrimSpace(item.Path))
if requestedPath == "." {
requestedPath = ""
}
for _, disk := range disks {
mountPoint := filepath.Clean(disk.MountPoint)
managedPath := managedStoragePath(mountPoint)
mountMatches := requestedMount != "" && requestedMount == mountPoint
pathMatches := requestedPath != "" && requestedPath == managedPath
if !mountMatches && !pathMatches {
continue
}
if requestedMount != "" && !mountMatches {
return storageDiskInfo{}, "", fmt.Errorf("storage disk mount point has changed; refresh and try again")
}
if requestedPath != "" && !pathMatches {
return storageDiskInfo{}, "", fmt.Errorf("custom storage paths are not allowed; refresh and try again")
}
return disk, managedPath, nil
}
return storageDiskInfo{}, "", fmt.Errorf("storage disk is not mounted or is no longer available")
}
func storagePoolIdentity(disk storageDiskInfo) (string, string) {
mountPoint := filepath.Clean(disk.MountPoint)
if mountPoint == string(os.PathSeparator) {
return "disk-root", "system (/)"
}
baseName := filepath.Base(mountPoint)
if baseName == "" || baseName == "." || baseName == string(os.PathSeparator) {
baseName = strings.TrimSpace(disk.Name)
}
if baseName == "" {
baseName = "storage"
}
devicePath := strings.TrimSpace(disk.Path)
if devicePath == "" {
devicePath = strings.TrimSpace(disk.Name)
}
return "disk-" + storageID(baseName), fmt.Sprintf("%s (%s)", baseName, devicePath)
}
func managedStoragePath(mountPoint string) string {
if filepath.Clean(mountPoint) == string(os.PathSeparator) {
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
}
return filepath.Join(filepath.Clean(mountPoint), "clicd")
}
func normalizeStorageContentTypes(values []string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range values {
var next string
switch strings.ToLower(strings.TrimSpace(value)) {
case config.StorageContentLXC:
next = config.StorageContentLXC
case config.StorageContentKVM:
next = config.StorageContentKVM
case config.StorageContentImages:
next = config.StorageContentImages
case config.StorageContentSnapshots:
next = config.StorageContentSnapshots
case config.StorageContentBackups:
next = config.StorageContentBackups
default:
continue
}
if seen[next] {
continue
}
seen[next] = true
result = append(result, next)
}
return result
}
func storageID(name string) string {
id := strings.ToLower(strings.TrimSpace(name))
id = strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-").Replace(id)
id = strings.Trim(id, "-")
if id == "" {
return "storage"
}
return id
}
func detectStorageDisks() []storageDiskInfo {
type lsblkDevice struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
FSType string `json:"fstype"`
MountPoint string `json:"mountpoint"`
Model string `json:"model"`
Size int64 `json:"size"`
ReadOnly bool `json:"ro"`
Children []lsblkDevice `json:"children"`
}
var payload struct {
BlockDevices []lsblkDevice `json:"blockdevices"`
}
out, err := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RO").Output()
if err != nil {
return nil
}
if err := json.Unmarshal(out, &payload); err != nil {
return nil
}
result := []storageDiskInfo{}
var walk func(lsblkDevice)
walk = func(dev lsblkDevice) {
info := storageDiskInfo{
Name: dev.Name,
Path: dev.Path,
Type: dev.Type,
FSType: dev.FSType,
MountPoint: dev.MountPoint,
Model: strings.TrimSpace(dev.Model),
SizeBytes: dev.Size,
}
if isUsableStorageMount(dev.Type, dev.FSType, dev.Path, dev.MountPoint, dev.ReadOnly) && !mountIsReadOnly(dev.MountPoint) {
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(dev.MountPoint)
result = append(result, info)
}
for _, child := range dev.Children {
walk(child)
}
}
for _, dev := range payload.BlockDevices {
walk(dev)
}
return result
}
func isUsableStorageMount(deviceType, fsType, devicePath, mountPoint string, readOnly bool) bool {
if readOnly || strings.TrimSpace(mountPoint) == "" || !strings.HasPrefix(mountPoint, "/") {
return false
}
deviceType = strings.ToLower(strings.TrimSpace(deviceType))
devicePath = strings.ToLower(strings.TrimSpace(devicePath))
if deviceType == "loop" || deviceType == "rom" || deviceType == "zram" || strings.HasPrefix(devicePath, "/dev/loop") {
return false
}
fsType = strings.ToLower(strings.TrimSpace(fsType))
unsupportedFileSystems := map[string]bool{
"": true,
"squashfs": true,
"iso9660": true,
"udf": true,
"swap": true,
"tmpfs": true,
"devtmpfs": true,
"overlay": true,
"proc": true,
"sysfs": true,
"cgroup": true,
"cgroup2": true,
"efivarfs": true,
"securityfs": true,
}
if unsupportedFileSystems[fsType] {
return false
}
mountPoint = pathpkg.Clean(mountPoint)
for _, reserved := range []string{"/snap", "/boot"} {
if mountPoint == reserved || strings.HasPrefix(mountPoint, reserved+"/") {
return false
}
}
return true
}
func mountIsReadOnly(mountPoint string) bool {
out, err := exec.Command("findmnt", "-n", "-o", "OPTIONS", "--target", mountPoint).Output()
if err != nil {
return false
}
for _, option := range strings.Split(strings.TrimSpace(string(out)), ",") {
if strings.TrimSpace(option) == "ro" {
return true
}
}
return false
}
func contentUsageForPool(poolPath string) ([]storageContentUsage, int64) {
mapping := map[string]string{
config.StorageContentLXC: "lxc",
config.StorageContentKVM: "kvm",
config.StorageContentImages: "images",
config.StorageContentSnapshots: "snapshots",
config.StorageContentBackups: "backups",
}
result := make([]storageContentUsage, 0, len(mapping))
var total int64
for _, content := range []string{
config.StorageContentLXC,
config.StorageContentKVM,
config.StorageContentImages,
config.StorageContentSnapshots,
config.StorageContentBackups,
} {
size := dirSizeBytes(filepath.Join(poolPath, mapping[content]))
result = append(result, storageContentUsage{ContentType: content, SizeBytes: size})
total += size
}
return result, total
}
func mergeContentUsage(current []storageContentUsage, next []storageContentUsage) []storageContentUsage {
sizes := map[string]int64{}
order := []string{}
for _, item := range append(current, next...) {
if _, ok := sizes[item.ContentType]; !ok {
order = append(order, item.ContentType)
}
sizes[item.ContentType] += item.SizeBytes
}
result := make([]storageContentUsage, 0, len(order))
for _, content := range order {
result = append(result, storageContentUsage{ContentType: content, SizeBytes: sizes[content]})
}
return result
}
func dirSizeBytes(path string) int64 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
// Count allocated blocks on this filesystem only. LXC rootfs directories can
// contain active mounts such as proc/sys; traversing them is slow and reports
// enormous virtual sizes that are not actually occupied by CLICD data.
out, err := exec.Command("du", "-skx", path).Output()
if err == nil {
fields := strings.Fields(string(out))
if len(fields) > 0 {
var sizeKB int64
if _, scanErr := fmt.Sscanf(fields[0], "%d", &sizeKB); scanErr == nil && sizeKB <= (1<<63-1)/1024 {
return sizeKB * 1024
}
}
}
var size int64
_ = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if info, statErr := d.Info(); statErr == nil {
size += info.Size()
}
return nil
})
return size
}
func dfPath(path string) (size int64, used int64, free int64) {
out, err := exec.Command("df", "-B1", "-P", path).Output()
if err != nil {
return 0, 0, 0
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0, 0, 0
}
fields := strings.Fields(lines[len(lines)-1])
if len(fields) < 6 {
return 0, 0, 0
}
fmt.Sscanf(fields[1], "%d", &size)
fmt.Sscanf(fields[2], "%d", &used)
fmt.Sscanf(fields[3], "%d", &free)
return size, used, free
}
func bestMountPointForPath(path string, disks []storageDiskInfo) string {
path = strings.ReplaceAll(path, "\\", "/")
path = pathpkg.Clean(path)
best := ""
for _, disk := range disks {
mp := pathpkg.Clean(strings.ReplaceAll(disk.MountPoint, "\\", "/"))
if disk.MountPoint == "" || mp == "." {
continue
}
matches := path == mp
if mp == "/" {
matches = pathpkg.IsAbs(path)
} else if strings.HasPrefix(path, mp+"/") {
matches = true
}
if matches {
if len(mp) > len(best) {
best = mp
}
}
}
return best
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"os"
"path/filepath"
"runtime"
"testing"
"clicd/internal/config"
)
func TestIsUsableStorageMount(t *testing.T) {
tests := []struct {
name string
deviceType string
fsType string
devicePath string
mountPoint string
readOnly bool
wantUsable bool
}{
{name: "root partition", deviceType: "part", fsType: "ext4", devicePath: "/dev/sda2", mountPoint: "/", wantUsable: true},
{name: "mounted data disk", deviceType: "disk", fsType: "xfs", devicePath: "/dev/sdb", mountPoint: "/data", wantUsable: true},
{name: "snap loop", deviceType: "loop", fsType: "squashfs", devicePath: "/dev/loop0", mountPoint: "/snap/core20/2105", readOnly: true},
{name: "loop without ro flag", deviceType: "loop", fsType: "ext4", devicePath: "/dev/loop7", mountPoint: "/mnt/loop"},
{name: "read only disk", deviceType: "part", fsType: "ext4", devicePath: "/dev/sdc1", mountPoint: "/archive", readOnly: true},
{name: "optical image", deviceType: "rom", fsType: "iso9660", devicePath: "/dev/sr0", mountPoint: "/media/cdrom"},
{name: "efi partition", deviceType: "part", fsType: "vfat", devicePath: "/dev/sda1", mountPoint: "/boot/efi"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isUsableStorageMount(tt.deviceType, tt.fsType, tt.devicePath, tt.mountPoint, tt.readOnly)
if got != tt.wantUsable {
t.Fatalf("isUsableStorageMount() = %v, want %v", got, tt.wantUsable)
}
})
}
}
func TestBestMountPointForPath(t *testing.T) {
disks := []storageDiskInfo{
{Path: "/dev/sda2", MountPoint: "/"},
{Path: "/dev/sdb1", MountPoint: "/mnt/clicd-data"},
}
tests := []struct {
path string
want string
}{
{path: "/var/lib/clicd", want: "/"},
{path: "/mnt/clicd-data/clicd", want: "/mnt/clicd-data"},
{path: "/mnt/clicd-data", want: "/mnt/clicd-data"},
}
for _, tt := range tests {
if got := bestMountPointForPath(tt.path, disks); got != tt.want {
t.Fatalf("bestMountPointForPath(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}
func TestNormalizeStoragePoolsUsesServerManagedPath(t *testing.T) {
disks := []storageDiskInfo{
{Path: "/dev/sda2", MountPoint: "/"},
{Path: "/dev/sdb1", MountPoint: "/mnt/data"},
}
items := []config.StoragePool{{
ID: "disk-data",
Name: "data",
Path: "/mnt/data/clicd",
MountPoint: "/mnt/data",
ContentTypes: []string{config.StorageContentLXC},
DefaultContents: []string{config.StorageContentLXC},
Enabled: true,
}}
pools, err := normalizeStoragePoolsRequestWithDisks(items, disks)
if err != nil {
t.Fatal(err)
}
wantPath := filepath.Join(filepath.Clean("/mnt/data"), "clicd")
if len(pools) != 1 || pools[0].ID != "disk-data" || pools[0].Name != "data (/dev/sdb1)" || pools[0].Path != wantPath || pools[0].MountPoint != "/mnt/data" {
t.Fatalf("unexpected normalized pools: %#v", pools)
}
}
func TestNormalizeStoragePoolsRejectsUncontrolledPath(t *testing.T) {
disks := []storageDiskInfo{{Path: "/dev/sdb1", MountPoint: "/mnt/data"}}
for _, path := range []string{"/etc", "/mnt/data/clicd/../../etc", "/mnt/data/other"} {
_, err := normalizeStoragePoolsRequestWithDisks([]config.StoragePool{{
ID: "disk-data",
Name: "data",
Path: path,
MountPoint: "/mnt/data",
Enabled: true,
}}, disks)
if err == nil {
t.Fatalf("path %q was accepted", path)
}
}
}
func TestDirSizeBytesUsesAllocatedBlocks(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("allocated-block behavior is provided by the Linux du command")
}
dir := t.TempDir()
file, err := os.Create(filepath.Join(dir, "sparse.img"))
if err != nil {
t.Fatal(err)
}
if err := file.Truncate(1 << 30); err != nil {
file.Close()
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if got := dirSizeBytes(dir); got >= 128<<20 {
t.Fatalf("dirSizeBytes() = %d, expected allocated size instead of 1 GiB apparent size", got)
}
}
+232 -43
View File
@@ -22,24 +22,30 @@ func generateRandomStr(length int) string {
}
type subUserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
CurrentImageIDs []string `json:"current_image_ids,omitempty"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
}
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
return subUserResponse{
ID: su.ID,
Username: su.Username,
Password: password,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
CreatedAt: su.CreatedAt,
ID: su.ID,
Username: su.Username,
Password: password,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
ImageLimitConfigured: su.ImageLimitConfigured,
CurrentImageIDs: subUserCurrentImageIDs(&su),
AccessCode: su.AccessCode,
CreatedAt: su.CreatedAt,
}
}
@@ -94,6 +100,10 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
}
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
if !su.ImageLimitConfigured && len(su.AllowedImageIDs) == 0 {
su.AllowedImageIDs = effectiveContainerAllowedImageIDs(c)
su.ImageLimitConfigured = true
}
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -114,14 +124,16 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
accessCode := generateRandomStr(8)
subUser := config.SubUser{
ID: "sub-" + generateRandomStr(8),
Username: username,
Password: password,
PassHash: string(hash),
ContainerNames: []string{containerName},
ContainerUUIDs: []string{c.UUID},
AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
ID: "sub-" + generateRandomStr(8),
Username: username,
Password: password,
PassHash: string(hash),
ContainerNames: []string{containerName},
ContainerUUIDs: []string{c.UUID},
AllowedImageIDs: effectiveContainerAllowedImageIDs(c),
ImageLimitConfigured: true,
AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
@@ -306,6 +318,155 @@ func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
return subUserAllowedContainers(r)
}
func subUserFromRequest(r *http.Request) *config.SubUser {
username := ""
if ctx, ok := authContextFromRequest(r); ok && ctx.Type == authTypeSubUser {
username = ctx.Username
}
if username == "" {
if claims, ok := claimsFromRequest(r); ok {
username, _ = claims["sub_user"].(string)
}
}
if username == "" {
return nil
}
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == username {
return &config.AppConfig.SubUsers[i]
}
}
return nil
}
func normalizeAllowedImageIDs(ids []string) ([]string, error) {
seen := map[string]bool{}
result := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
if !imageTemplateExists(id) {
return nil, fmt.Errorf("unknown image template: %s", id)
}
seen[id] = true
result = append(result, id)
}
return result, nil
}
func isTemplateAllowedForRequest(r *http.Request, c *config.Container, templateID string) bool {
if !isSubUserRequest(r) {
return true
}
return isImageAllowedForSubUser(subUserFromRequest(r), c, templateID)
}
func isImageAllowedForSubUser(su *config.SubUser, c *config.Container, templateID string) bool {
if su == nil || strings.TrimSpace(templateID) == "" {
return false
}
for _, id := range effectiveSubUserAllowedImageIDs(su) {
if id == templateID {
return true
}
}
return false
}
func effectiveContainerAllowedImageIDs(c *config.Container) []string {
if c == nil {
return nil
}
if c.ImageLimitConfigured || len(c.AllowedImageIDs) > 0 {
return cleanImageIDList(c.AllowedImageIDs)
}
if c.Template != "" {
return []string{c.Template}
}
return nil
}
func effectiveSubUserAllowedImageIDs(su *config.SubUser) []string {
if su == nil {
return nil
}
if su.ImageLimitConfigured || len(su.AllowedImageIDs) > 0 {
return cleanImageIDList(su.AllowedImageIDs)
}
result := []string{}
seen := map[string]bool{}
for _, c := range subUserAssignedContainers(su) {
for _, id := range effectiveContainerAllowedImageIDs(c) {
if id != "" && !seen[id] {
seen[id] = true
result = append(result, id)
}
}
}
return result
}
func cleanImageIDList(ids []string) []string {
result := make([]string, 0, len(ids))
seen := map[string]bool{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
result = append(result, id)
}
return result
}
func subUserCurrentImageIDs(su *config.SubUser) []string {
seen := map[string]bool{}
result := []string{}
for _, c := range subUserAssignedContainers(su) {
if c.Template != "" && !seen[c.Template] {
seen[c.Template] = true
result = append(result, c.Template)
}
}
return result
}
func subUserAssignedContainers(su *config.SubUser) []*config.Container {
if su == nil {
return nil
}
result := []*config.Container{}
seen := map[string]bool{}
for _, uuid := range su.ContainerUUIDs {
if c := config.FindContainerByUUID(uuid); c != nil {
key := c.UUID
if key == "" {
key = c.Name
}
if !seen[key] {
seen[key] = true
result = append(result, c)
}
}
}
for _, name := range su.ContainerNames {
if c := config.FindContainerByName(name); c != nil {
key := c.UUID
if key == "" {
key = c.Name
}
if !seen[key] {
seen[key] = true
result = append(result, c)
}
}
}
return result
}
func isAccessRestrictedRequest(r *http.Request) bool {
_, restricted := requestAllowedContainers(r)
return restricted
@@ -485,7 +646,7 @@ func isSubUserBlockedAction(action string, method string) bool {
return method != http.MethodGet
}
switch action {
case "usage", "traffic":
case "usage", "traffic", "history":
return method != http.MethodGet
default:
return true
@@ -504,7 +665,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
return method == http.MethodGet
}
switch {
case action == "usage" || action == "traffic" || action == "random-port":
case action == "usage" || action == "traffic" || action == "history" || action == "random-port":
return method == http.MethodGet
case action == "snapshots":
return method == http.MethodGet || method == http.MethodPost
@@ -580,18 +741,21 @@ func splitBy(s, sep string) []string {
// SubUserListItem is the enriched sub-user info returned by the list API
type SubUserListItem struct {
ID string `json:"id"`
Username string `json:"username"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"`
ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"`
Password string `json:"password,omitempty"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login"`
LastLoginIP string `json:"last_login_ip"`
LastLoginUA string `json:"last_login_ua"`
ID string `json:"id"`
Username string `json:"username"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"`
AllowedImageIDs []string `json:"allowed_image_ids"`
ImageLimitConfigured bool `json:"image_limit_configured"`
CurrentImageIDs []string `json:"current_image_ids"`
ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"`
Password string `json:"password,omitempty"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login"`
LastLoginIP string `json:"last_login_ip"`
LastLoginUA string `json:"last_login_ua"`
}
// HandleSubUserList returns the list of all sub-users with container info
@@ -607,13 +771,16 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers {
item := SubUserListItem{
ID: su.ID,
Username: su.Username,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
Password: su.Password,
CreatedAt: su.CreatedAt,
ID: su.ID,
Username: su.Username,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
ImageLimitConfigured: su.ImageLimitConfigured,
CurrentImageIDs: subUserCurrentImageIDs(&su),
AccessCode: su.AccessCode,
Password: su.Password,
CreatedAt: su.CreatedAt,
}
// Resolve container name from first active UUID
@@ -711,6 +878,28 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "images" && r.Method == http.MethodPut:
if !requireScope(w, r, "subuser:update") {
return
}
var req struct {
AllowedImageIDs []string `json:"allowed_image_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
ids, err := normalizeAllowedImageIDs(req.AllowedImageIDs)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
target.AllowedImageIDs = ids
target.ImageLimitConfigured = true
target.TokenVersion++
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: newSubUserResponse(*target, target.Password)})
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
}
+347 -179
View File
@@ -30,6 +30,8 @@ type Task struct {
ContainerName string `json:"container_name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Stage string `json:"stage,omitempty"`
StageDetail string `json:"stage_detail,omitempty"`
CreatedAt string `json:"created_at"`
TemplateID string `json:"template_id,omitempty"`
Config lxc.ContainerConfig `json:"config,omitempty"`
@@ -37,30 +39,74 @@ type Task struct {
User string `json:"user,omitempty"` // who created this task
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
activeKey string
}
type TaskQueue struct {
mu sync.Mutex
createQueue []*Task
opQueue []*Task
tasks map[string]*Task
nextID int
createCond *sync.Cond
opCond *sync.Cond
stop chan struct{}
mu sync.Mutex
createQueue []*Task
opQueue []*Task
tasks map[string]*Task
nextID int
createCond *sync.Cond
opCond *sync.Cond
maxConcurrency int
activeTasks int
activeTargets map[string]bool
stop chan struct{}
}
type TaskQueueSettings struct {
Concurrency int `json:"concurrency"`
Active int `json:"active"`
Pending int `json:"pending"`
}
var globalQueue *TaskQueue
func init() {
globalQueue = &TaskQueue{
tasks: make(map[string]*Task),
stop: make(chan struct{}),
globalQueue = newTaskQueue(config.DefaultTaskConcurrency)
go globalQueue.createDispatcher()
go globalQueue.opDispatcher()
}
func newTaskQueue(concurrency int) *TaskQueue {
q := &TaskQueue{
tasks: make(map[string]*Task),
maxConcurrency: config.NormalizeTaskConcurrency(concurrency),
activeTargets: make(map[string]bool),
stop: make(chan struct{}),
}
globalQueue.createCond = sync.NewCond(&globalQueue.mu)
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
go globalQueue.createWorker()
go globalQueue.opWorker()
q.createCond = sync.NewCond(&q.mu)
q.opCond = sync.NewCond(&q.mu)
return q
}
func ConfigureTaskQueue(concurrency int) {
globalQueue.SetConcurrency(concurrency)
}
func (q *TaskQueue) SetConcurrency(concurrency int) {
q.mu.Lock()
q.maxConcurrency = config.NormalizeTaskConcurrency(concurrency)
q.createCond.Broadcast()
q.opCond.Broadcast()
q.mu.Unlock()
}
func (q *TaskQueue) Settings() TaskQueueSettings {
q.mu.Lock()
defer q.mu.Unlock()
return TaskQueueSettings{
Concurrency: q.maxConcurrency,
Active: q.activeTasks,
Pending: len(q.createQueue) + len(q.opQueue),
}
}
func (q *TaskQueue) signalDispatchers() {
q.createCond.Broadcast()
q.opCond.Broadcast()
}
func (q *TaskQueue) enqueueTask(task *Task) {
@@ -90,6 +136,8 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task
ContainerID: containerID,
ContainerName: containerName,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
@@ -172,6 +220,8 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
ContainerID: 0,
ContainerName: cfgCopy.Name,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy,
User: user,
@@ -202,6 +252,8 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
ContainerID: containerID,
ContainerName: containerName,
Status: "pending",
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
@@ -262,176 +314,251 @@ func (q *TaskQueue) CancelPendingSecurityStops() int {
return cancelled
}
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
// If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}.
func (q *TaskQueue) createWorker() {
// The two dispatchers keep long-running creates from blocking power operations,
// while sharing one global concurrency budget.
func (q *TaskQueue) createDispatcher() {
for {
q.mu.Lock()
for len(q.createQueue) == 0 {
q.createCond.Wait()
}
task := q.createQueue[0]
q.createQueue = q.createQueue[1:]
task.Status = "running"
q.mu.Unlock()
createdByTask := false
if task.Config.Name == "" {
task.Config.Name = task.ContainerName
}
task.Config.NormalizeResourceAliases()
if task.Config.Name == "" {
task.Status = "failed"
task.Error = "container name is required"
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
}
c := config.FindContainerByName(task.Config.Name)
if c == nil {
// 1) Download image + apply limits (lxc-create)
err := createByRuntime(task.Config)
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
}
createdByTask = true
// 2) Find created container by name
c = config.FindContainerByName(task.Config.Name)
if c == nil {
task.Status = "failed"
task.Error = "created but not found in config"
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
}
}
task.ContainerID = c.ID
task.ContainerName = c.Name
// 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains.
startErr := startByRuntime(c.ID)
if startErr != nil {
if createdByTask {
_ = destroyByRuntime(c.ID)
}
task.Status = "failed"
task.Error = startErr.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin")
} else {
task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
}
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
task := q.takeNextTask(true)
go q.runCreateTask(task)
}
}
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
// including the follow-up initialization after a create succeeds.
func (q *TaskQueue) opWorker() {
func (q *TaskQueue) opDispatcher() {
for {
q.mu.Lock()
for len(q.opQueue) == 0 {
q.opCond.Wait()
}
task := q.opQueue[0]
q.opQueue = q.opQueue[1:]
task.Status = "running"
q.mu.Unlock()
var err error
skipped := false
err = resolveTaskContainer(task)
// Block operations on expired or traffic-exceeded containers (except stop/delete)
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
c := config.FindContainer(task.ContainerID)
if c != nil {
if lxc.IsExpired(*c) {
err = fmt.Errorf("容器已到期,不允许此操作")
} else if lxc.IsTrafficExceeded(*c) {
err = fmt.Errorf("容器流量已超限,不允许此操作")
}
}
}
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
skipped = true
}
if err == nil {
if !skipped {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
case TaskStop:
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
}
}
case TaskReinstall:
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
}
}
q.mu.Lock()
auditUser := task.User
if auditUser == "" {
auditUser = "admin"
}
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else if skipped {
task.Status = "done"
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
} else {
task.Status = "done"
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
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()
q.mu.Unlock()
task := q.takeNextTask(false)
go q.runOperationTask(task)
}
}
func (q *TaskQueue) takeNextTask(create bool) *Task {
q.mu.Lock()
defer q.mu.Unlock()
cond := q.opCond
if create {
cond = q.createCond
}
for {
queue := q.opQueue
if create {
queue = q.createQueue
}
if q.activeTasks < q.maxConcurrency {
if index := runnableTaskIndex(queue, q.activeTargets); index >= 0 {
task := queue[index]
queue = append(queue[:index], queue[index+1:]...)
if create {
q.createQueue = queue
} else {
q.opQueue = queue
}
task.Status = "running"
task.Error = ""
task.Stage = "preparing"
task.StageDetail = "准备初始化环境"
task.activeKey = taskConcurrencyKey(task)
q.activeTargets[task.activeKey] = true
q.activeTasks++
q.persistTasks()
return task
}
}
cond.Wait()
}
}
func runnableTaskIndex(queue []*Task, activeTargets map[string]bool) int {
for index, task := range queue {
if !activeTargets[taskConcurrencyKey(task)] {
return index
}
}
return -1
}
func taskConcurrencyKey(task *Task) string {
if task == nil {
return "task:nil"
}
name := strings.TrimSpace(task.ContainerName)
if name == "" {
name = strings.TrimSpace(task.Config.Name)
}
if name != "" {
return "name:" + strings.ToLower(name)
}
if task.ContainerID > 0 {
return fmt.Sprintf("id:%d", task.ContainerID)
}
return "task:" + task.ID
}
func (q *TaskQueue) finishTask(task *Task, status string, taskErr error) {
q.mu.Lock()
task.Status = status
if taskErr != nil {
task.Error = taskErr.Error()
if task.Type == TaskCreate {
task.Stage = "failed"
task.StageDetail = "初始化失败"
}
} else {
task.Error = ""
if task.Type == TaskCreate {
task.Stage = "completed"
task.StageDetail = "初始化完成"
}
}
if task.activeKey != "" {
delete(q.activeTargets, task.activeKey)
task.activeKey = ""
}
if q.activeTasks > 0 {
q.activeTasks--
}
q.persistTasks()
q.signalDispatchers()
q.mu.Unlock()
}
func (q *TaskQueue) updateTaskStage(task *Task, stage, detail string) {
q.mu.Lock()
task.Stage = stage
task.StageDetail = detail
q.mu.Unlock()
}
// runCreateTask handles lxc-create, resource setup, start, and SSH init. A
// restored task resumes initialization when the same-name container exists.
func (q *TaskQueue) runCreateTask(task *Task) {
q.mu.Lock()
createdByTask := false
if task.Config.Name == "" {
task.Config.Name = task.ContainerName
}
task.Config.NormalizeResourceAliases()
cfg := task.Config
q.mu.Unlock()
cfg.Progress = func(stage, detail string) {
q.updateTaskStage(task, stage, detail)
}
if cfg.Name == "" {
err := fmt.Errorf("container name is required")
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
c := config.FindContainerByName(cfg.Name)
if c == nil {
if err := createByRuntime(cfg); err != nil {
config.AddAuditLog(string(task.Type), cfg.Name, "失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
createdByTask = true
c = config.FindContainerByName(cfg.Name)
if c == nil {
err := fmt.Errorf("created but not found in config")
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
}
q.mu.Lock()
task.ContainerID = c.ID
task.ContainerName = c.Name
q.mu.Unlock()
startDetail := "启动容器并等待网络就绪"
if strings.EqualFold(cfg.Virtualization, config.VirtualizationKVM) {
startDetail = "启动虚拟机并等待网络就绪"
}
q.updateTaskStage(task, "starting", startDetail)
if err := startByRuntime(c.ID); err != nil {
if createdByTask {
_ = destroyByRuntime(c.ID)
}
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+err.Error(), "admin")
q.finishTask(task, "failed", err)
return
}
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
q.finishTask(task, "done", nil)
}
func (q *TaskQueue) runOperationTask(task *Task) {
q.mu.Lock()
err := resolveTaskContainer(task)
q.mu.Unlock()
skipped := false
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
c := config.FindContainer(task.ContainerID)
if c != nil {
if lxc.IsExpired(*c) {
err = fmt.Errorf("容器已到期,不允许此操作")
} else if lxc.IsTrafficExceeded(*c) {
err = fmt.Errorf("容器流量已超限,不允许此操作")
}
}
}
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
skipped = true
}
if err == nil && !skipped {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
case TaskStop:
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
}
}
case TaskReinstall:
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
}
auditUser := task.User
if auditUser == "" {
auditUser = "admin"
}
if err != nil {
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
q.finishTask(task, "failed", err)
return
}
if skipped {
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
q.finishTask(task, "done", nil)
return
}
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
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.finishTask(task, "done", nil)
}
func isSecurityStopTask(task *Task) bool {
return task != nil && task.Type == TaskStop && task.User == "system:security"
}
@@ -503,7 +630,8 @@ func (q *TaskQueue) GetTasks() []*Task {
result := make([]*Task, 0, len(q.tasks))
// Collect all task IDs, sort by creation time (extracted from ID number)
for _, t := range q.tasks {
result = append(result, t)
copyTask := *t
result = append(result, &copyTask)
}
// Stable sort by ID number (task-N where N is sequential)
for i := 0; i < len(result); i++ {
@@ -560,7 +688,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
if c := config.FindContainer(id); c != nil {
runtime = c.Runtime()
}
if !isImageEnabledAndDownloaded(templateID, runtime) {
if !isTemplateAllowedForRequest(r, c, templateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not allowed for this user"})
return
}
if !isTemplateAvailableForRequest(r, c, templateID, runtime) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
@@ -646,16 +778,30 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
}
req.Containers[i].NormalizeResourceAliases()
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].WantsLANIPv4() && req.Containers[i].Virtualization != config.VirtualizationLXC {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": LAN IPv4 is only supported for LXC containers"})
return
}
if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512
}
if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5
}
if err := validateCreateStoragePool(&req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return
}
if ids, err := normalizeAllowedImageIDs(req.Containers[i].AllowedImageIDs); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
} else {
req.Containers[i].AllowedImageIDs = ids
}
if req.Containers[i].PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
return
@@ -777,6 +923,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return
}
if taskType == TaskReinstall && !isTemplateAllowedForRequest(r, c, req.TemplateID) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: c.Name + ": template is not allowed for this user"})
return
}
if taskConfig != nil {
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
@@ -861,6 +1011,8 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
// RestoreTasks restores task queue from config
func RestoreTasks() {
globalQueue.mu.Lock()
defer globalQueue.mu.Unlock()
for _, st := range config.AppConfig.Tasks {
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
continue
@@ -890,6 +1042,8 @@ func RestoreTasks() {
ContainerName: containerName,
Status: st.Status,
Error: st.Error,
Stage: "queued",
StageDetail: "排队等待",
CreatedAt: st.CreatedAt,
TemplateID: st.TemplateID,
Config: cfg,
@@ -919,3 +1073,17 @@ func parseIDNum(id string) int {
}
return num
}
func validateCreateStoragePool(cfg *lxc.ContainerConfig) error {
required := config.StorageContentLXC
if cfg.Virtualization == config.VirtualizationKVM {
required = config.StorageContentKVM
}
requiredBytes := int64(cfg.DiskGB) * 1024 * 1024 * 1024
pool, err := config.SelectStoragePoolForContent(required, cfg.StoragePoolID, requiredBytes)
if err != nil {
return err
}
cfg.StoragePoolID = pool.ID
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"testing"
"clicd/internal/config"
"clicd/internal/lxc"
)
func TestRunnableTaskIndexSkipsActiveContainer(t *testing.T) {
queue := []*Task{
{ID: "task-1", Type: TaskStop, ContainerID: 1, ContainerName: "alpha"},
{ID: "task-2", Type: TaskStart, ContainerID: 1, ContainerName: "alpha"},
{ID: "task-3", Type: TaskStart, ContainerID: 2, ContainerName: "beta"},
}
active := map[string]bool{taskConcurrencyKey(queue[0]): true}
if got := runnableTaskIndex(queue[1:], active); got != 1 {
t.Fatalf("runnableTaskIndex() = %d, want 1 for the other container", got)
}
}
func TestTaskConcurrencyKeyUsesContainerName(t *testing.T) {
create := &Task{ID: "task-1", Type: TaskCreate, Config: lxcConfigWithName("Example")}
operation := &Task{ID: "task-2", Type: TaskDelete, ContainerID: 9, ContainerName: "example"}
if taskConcurrencyKey(create) != taskConcurrencyKey(operation) {
t.Fatalf("same container received different concurrency keys: %q and %q", taskConcurrencyKey(create), taskConcurrencyKey(operation))
}
}
func TestTaskQueueSetConcurrencyNormalizesAndReports(t *testing.T) {
q := newTaskQueue(config.DefaultTaskConcurrency)
q.SetConcurrency(config.MaxTaskConcurrency + 10)
if got := q.Settings().Concurrency; got != config.MaxTaskConcurrency {
t.Fatalf("concurrency = %d, want %d", got, config.MaxTaskConcurrency)
}
q.SetConcurrency(0)
if got := q.Settings().Concurrency; got != config.DefaultTaskConcurrency {
t.Fatalf("concurrency = %d, want default %d", got, config.DefaultTaskConcurrency)
}
}
func TestTaskQueueUpdateTaskStage(t *testing.T) {
q := newTaskQueue(config.DefaultTaskConcurrency)
task := &Task{ID: "task-1", Type: TaskCreate, Status: "running"}
q.updateTaskStage(task, "rootfs", "下载模板并创建基础文件系统")
if task.Stage != "rootfs" || task.StageDetail != "下载模板并创建基础文件系统" {
t.Fatalf("unexpected task stage: %q %q", task.Stage, task.StageDetail)
}
}
func lxcConfigWithName(name string) lxc.ContainerConfig {
return lxc.ContainerConfig{Name: name}
}
+484 -10
View File
@@ -5,9 +5,12 @@ import (
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
@@ -109,6 +112,8 @@ type Container struct {
LXCName string `json:"lxc_name,omitempty"`
KVMName string `json:"kvm_name,omitempty"`
DiskImage string `json:"disk_image,omitempty"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
StoragePath string `json:"storage_path,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
@@ -128,7 +133,13 @@ type Container struct {
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
Status string `json:"status"`
RestoreOnHostBoot bool `json:"restore_on_host_boot,omitempty"`
IP string `json:"ip"`
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
LANInterface string `json:"lan_interface,omitempty"`
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
@@ -143,6 +154,8 @@ type Container struct {
FirewallEnabled bool `json:"firewall_enabled"`
FirewallDefaultAction string `json:"firewall_default_action"`
FirewallRules []FirewallRule `json:"firewall_rules"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
@@ -160,6 +173,9 @@ type Container struct {
const (
VirtualizationLXC = "lxc"
VirtualizationKVM = "kvm"
LANIPv4ModeDHCP = "dhcp"
LANIPv4ModeStatic = "static"
)
func NormalizeVirtualization(value string) string {
@@ -179,8 +195,373 @@ func (c *Container) IsKVM() bool {
return c.Runtime() == VirtualizationKVM
}
func (c *Container) UsesLANDHCP() bool {
return strings.EqualFold(strings.TrimSpace(c.LANIPv4Mode), LANIPv4ModeDHCP)
}
func (c *Container) UsesLANStaticIPv4() bool {
return strings.EqualFold(strings.TrimSpace(c.LANIPv4Mode), LANIPv4ModeStatic)
}
func (c *Container) UsesLANIPv4() bool {
return c.UsesLANDHCP() || c.UsesLANStaticIPv4()
}
func normalizeStoragePools() bool {
if AppConfig == nil {
return false
}
changed := false
result := make([]StoragePool, 0, len(AppConfig.StoragePools))
seen := map[string]bool{}
defaultSeen := map[string]bool{}
for _, pool := range AppConfig.StoragePools {
pool.ID = strings.TrimSpace(pool.ID)
pool.Name = strings.TrimSpace(pool.Name)
pool.Path = filepath.Clean(strings.TrimSpace(pool.Path))
pool.MountPoint = filepath.Clean(strings.TrimSpace(pool.MountPoint))
if pool.MountPoint == "." {
pool.MountPoint = ""
}
if pool.MountPoint != "" {
managedPath := managedStoragePoolPath(pool.MountPoint)
if pool.Path != managedPath {
pool.Path = managedPath
changed = true
}
}
if pool.ID == "" {
pool.ID = storagePoolIDFromName(pool.Name, pool.Path)
changed = true
}
if pool.Name == "" {
pool.Name = pool.ID
changed = true
}
if pool.Path == "." || !filepath.IsAbs(pool.Path) || seen[pool.ID] {
changed = true
continue
}
seen[pool.ID] = true
pool.ContentTypes = normalizeStorageContentTypes(pool.ContentTypes)
pool.DefaultContents = normalizeStorageContentTypes(pool.DefaultContents)
allowed := map[string]bool{}
for _, content := range pool.ContentTypes {
allowed[content] = true
}
defaults := make([]string, 0, len(pool.DefaultContents))
for _, content := range pool.DefaultContents {
if !allowed[content] || defaultSeen[content] {
changed = true
continue
}
defaultSeen[content] = true
defaults = append(defaults, content)
}
pool.DefaultContents = defaults
if pool.ContentTypes == nil {
pool.ContentTypes = []string{}
}
result = append(result, pool)
}
if len(result) != len(AppConfig.StoragePools) {
changed = true
}
AppConfig.StoragePools = result
return changed
}
func managedStoragePoolPath(mountPoint string) string {
mountPoint = filepath.Clean(strings.TrimSpace(mountPoint))
if mountPoint == string(os.PathSeparator) {
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
}
return filepath.Join(mountPoint, "clicd")
}
func storagePoolIDFromName(name, path string) string {
base := strings.ToLower(strings.TrimSpace(name))
if base == "" {
base = filepath.Base(filepath.Clean(path))
}
replacer := strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-")
base = replacer.Replace(base)
base = strings.Trim(base, "-")
if base == "" {
base = "storage"
}
return base
}
func normalizeStorageContentTypes(values []string) []string {
if len(values) == 0 {
return nil
}
valid := map[string]bool{
StorageContentLXC: true,
StorageContentKVM: true,
StorageContentImages: true,
StorageContentSnapshots: true,
StorageContentBackups: true,
}
seen := map[string]bool{}
result := []string{}
for _, value := range values {
next := strings.ToLower(strings.TrimSpace(value))
if !valid[next] || seen[next] {
continue
}
seen[next] = true
result = append(result, next)
}
return result
}
func StoragePoolsForContent(content string) []StoragePool {
if AppConfig == nil {
return nil
}
content = strings.ToLower(strings.TrimSpace(content))
result := []StoragePool{}
for _, pool := range AppConfig.StoragePools {
if !pool.Enabled || !storagePoolAllows(pool, content) {
continue
}
result = append(result, pool)
}
return result
}
func StoragePoolByID(id string) *StoragePool {
if AppConfig == nil {
return nil
}
id = strings.TrimSpace(id)
for i := range AppConfig.StoragePools {
if AppConfig.StoragePools[i].ID == id {
return &AppConfig.StoragePools[i]
}
}
return nil
}
func StoragePoolAllowsContent(pool StoragePool, content string) bool {
return storagePoolAllows(pool, strings.ToLower(strings.TrimSpace(content)))
}
func StoragePathForContent(content, fallback string) string {
if pool := DefaultStoragePoolForContent(content); pool != nil {
return pool.Path
}
return fallback
}
// PreferredStoragePoolForContent returns the configured default without doing
// filesystem probes. Use SelectStoragePoolForContent for new writes.
func PreferredStoragePoolForContent(content string) *StoragePool {
if AppConfig == nil {
return nil
}
content = strings.ToLower(strings.TrimSpace(content))
for i := range AppConfig.StoragePools {
pool := &AppConfig.StoragePools[i]
if !pool.Enabled || !storagePoolAllows(*pool, content) {
continue
}
for _, item := range pool.DefaultContents {
if item == content {
return pool
}
}
}
for i := range AppConfig.StoragePools {
pool := &AppConfig.StoragePools[i]
if pool.Enabled && storagePoolAllows(*pool, content) {
return pool
}
}
return nil
}
func DefaultStoragePoolForContent(content string) *StoragePool {
pool, _ := SelectStoragePoolForContent(content, "", 0)
return pool
}
const storagePoolFreeReserveBytes int64 = 256 * 1024 * 1024
type storagePoolCandidate struct {
pool *StoragePool
freeBytes int64
isDefault bool
}
// SelectStoragePoolForContent picks a writable mounted pool. The requested or
// configured default pool is preferred while it has enough space; remaining
// pools are tried by available space from largest to smallest.
func SelectStoragePoolForContent(content, requestedPoolID string, requiredBytes int64) (*StoragePool, error) {
if AppConfig == nil {
return nil, fmt.Errorf("storage configuration is not loaded")
}
content = strings.ToLower(strings.TrimSpace(content))
requestedPoolID = strings.TrimSpace(requestedPoolID)
if requiredBytes < 0 {
requiredBytes = 0
}
requiredFree := requiredBytes + storagePoolFreeReserveBytes
candidates := make([]storagePoolCandidate, 0, len(AppConfig.StoragePools))
configured := 0
for i := range AppConfig.StoragePools {
pool := &AppConfig.StoragePools[i]
if !pool.Enabled || !storagePoolAllows(*pool, content) {
continue
}
configured++
freeBytes, available := probeStoragePoolFreeBytes(*pool)
if !available {
continue
}
candidate := storagePoolCandidate{pool: pool, freeBytes: freeBytes}
for _, item := range pool.DefaultContents {
if item == content {
candidate.isDefault = true
break
}
}
candidates = append(candidates, candidate)
}
if configured == 0 {
return nil, fmt.Errorf("no storage disk is enabled for %s", storageContentLabel(content))
}
if len(candidates) == 0 {
return nil, fmt.Errorf("all storage disks enabled for %s are unavailable or unmounted", storageContentLabel(content))
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].freeBytes > candidates[j].freeBytes
})
preferred := func(match func(storagePoolCandidate) bool) *StoragePool {
for _, candidate := range candidates {
if match(candidate) && candidate.freeBytes >= requiredFree {
return candidate.pool
}
}
return nil
}
if requestedPoolID != "" {
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.pool.ID == requestedPoolID }); pool != nil {
return pool, nil
}
}
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.isDefault }); pool != nil {
return pool, nil
}
if pool := preferred(func(storagePoolCandidate) bool { return true }); pool != nil {
return pool, nil
}
return nil, fmt.Errorf("storage disks enabled for %s do not have enough free space", storageContentLabel(content))
}
var probeStoragePoolFreeBytes = storagePoolFreeBytes
func storagePoolFreeBytes(pool StoragePool) (int64, bool) {
if strings.TrimSpace(pool.Path) == "" {
return 0, false
}
if _, err := os.Stat(pool.Path); err != nil {
if !os.IsNotExist(err) || filepath.Clean(pool.MountPoint) != string(os.PathSeparator) {
return 0, false
}
if err := os.MkdirAll(pool.Path, 0755); err != nil {
return 0, false
}
}
if mountPoint := strings.TrimSpace(pool.MountPoint); mountPoint != "" {
out, err := exec.Command("findmnt", "-n", "-o", "TARGET", "--target", pool.Path).Output()
if err != nil || filepath.Clean(strings.TrimSpace(string(out))) != filepath.Clean(mountPoint) {
return 0, false
}
}
out, err := exec.Command("df", "-B1", "-P", pool.Path).Output()
if err != nil {
return 0, false
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0, false
}
fields := strings.Fields(lines[len(lines)-1])
if len(fields) < 4 {
return 0, false
}
freeBytes, err := strconv.ParseInt(fields[3], 10, 64)
return freeBytes, err == nil
}
func storageContentLabel(content string) string {
switch content {
case StorageContentLXC:
return "LXC containers"
case StorageContentKVM:
return "KVM disks"
case StorageContentImages:
return "image cache"
case StorageContentSnapshots:
return "snapshots"
case StorageContentBackups:
return "backups"
default:
return content
}
}
func storagePoolAllows(pool StoragePool, content string) bool {
for _, item := range pool.ContentTypes {
if item == content {
return true
}
}
return false
}
func (c *Container) NormalizeNetworkAssignments() bool {
changed := false
lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode))
if lanMode != "" && lanMode != LANIPv4ModeDHCP && lanMode != LANIPv4ModeStatic {
lanMode = ""
}
if c.LANIPv4Mode != lanMode {
c.LANIPv4Mode = lanMode
changed = true
}
lanInterface := strings.TrimSpace(c.LANInterface)
if c.LANInterface != lanInterface {
c.LANInterface = lanInterface
changed = true
}
lanAddress := strings.TrimSpace(c.LANIPv4Address)
if c.LANIPv4Address != lanAddress {
c.LANIPv4Address = lanAddress
changed = true
}
lanGateway := strings.TrimSpace(c.LANIPv4Gateway)
if c.LANIPv4Gateway != lanGateway {
c.LANIPv4Gateway = lanGateway
changed = true
}
if c.LANIPv4Mode == LANIPv4ModeDHCP {
if c.LANIPv4Address != "" {
c.LANIPv4Address = ""
changed = true
}
} else if c.LANIPv4Mode != LANIPv4ModeStatic {
if c.LANIPv4Address != "" || c.LANIPv4PrefixLen != 0 || c.LANIPv4Gateway != "" {
c.LANIPv4Address = ""
c.LANIPv4PrefixLen = 0
c.LANIPv4Gateway = ""
changed = true
}
}
seenIPv4 := map[string]bool{}
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
for _, item := range c.PublicIPv4s {
@@ -319,16 +700,18 @@ func DeleteApiKey(id string) {
}
type SubUser struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
Token string `json:"-"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
TokenVersion int `json:"token_version"`
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
Token string `json:"-"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
TokenVersion int `json:"token_version"`
}
type Snapshot struct {
@@ -361,6 +744,43 @@ type SSLConfig struct {
LastError string `json:"last_error,omitempty"`
}
const (
StorageContentLXC = "lxc"
StorageContentKVM = "kvm"
StorageContentImages = "images"
StorageContentSnapshots = "snapshots"
StorageContentBackups = "backups"
)
type StoragePool struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
MountPoint string `json:"mount_point,omitempty"`
ContentTypes []string `json:"content_types"`
DefaultContents []string `json:"default_contents,omitempty"`
Enabled bool `json:"enabled"`
}
func defaultPrimaryStoragePool() StoragePool {
contents := []string{
StorageContentLXC,
StorageContentKVM,
StorageContentImages,
StorageContentSnapshots,
StorageContentBackups,
}
return StoragePool{
ID: "disk-root",
Name: "system (/)",
Path: "/var/lib/clicd",
MountPoint: "/",
ContentTypes: append([]string(nil), contents...),
DefaultContents: append([]string(nil), contents...),
Enabled: true,
}
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
@@ -386,16 +806,24 @@ type ClicdConfig struct {
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
TaskConcurrency int `json:"task_concurrency"`
Language string `json:"language"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
StoragePools []StoragePool `json:"storage_pools"`
}
var configPath string
var AppConfig *ClicdConfig
var allocationMu sync.Mutex
const DefaultSnapshotLimit = 3
const (
DefaultTaskConcurrency = 2
MaxTaskConcurrency = 16
)
const (
DefaultNATPortStart = 20000
DefaultNATPortEnd = 65535
@@ -527,6 +955,8 @@ func InitConfig() (*ClicdConfig, error) {
PublicIPv4Pool: []PublicIPv4Assignment{},
PublicIPv6Prefixes: []PublicIPv6Prefix{},
WebSSHAllowedOrigins: []string{},
TaskConcurrency: DefaultTaskConcurrency,
StoragePools: []StoragePool{defaultPrimaryStoragePool()},
}
if err := SaveConfig(); err != nil {
@@ -568,6 +998,10 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.NextContainerID = 1
changed = true
}
if normalized := NormalizeTaskConcurrency(AppConfig.TaskConcurrency); AppConfig.TaskConcurrency != normalized {
AppConfig.TaskConcurrency = normalized
changed = true
}
if AppConfig.DataDir == "" {
AppConfig.DataDir = dataDir
changed = true
@@ -595,6 +1029,13 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.WebSSHAllowedOrigins = normalized
changed = true
}
if len(AppConfig.StoragePools) == 0 {
AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()}
changed = true
}
if normalizeStoragePools() {
changed = true
}
if AppConfig.SubUsers == nil {
AppConfig.SubUsers = make([]SubUser, 0)
changed = true
@@ -640,6 +1081,16 @@ func normalizeConfigDefaults(dataDir string) bool {
return changed
}
func NormalizeTaskConcurrency(value int) int {
if value <= 0 {
return DefaultTaskConcurrency
}
if value > MaxTaskConcurrency {
return MaxTaskConcurrency
}
return value
}
func NormalizeLanguage(language string) string {
switch strings.ToLower(strings.TrimSpace(language)) {
case "en", "en-us", "en_us", "english":
@@ -986,6 +1437,8 @@ func SaveConfig() error {
// AddContainer adds a container to the config
func AddContainer(c Container) {
allocationMu.Lock()
defer allocationMu.Unlock()
if c.UUID == "" {
c.UUID = NewContainerUUID()
}
@@ -997,6 +1450,8 @@ func AddContainer(c Container) {
// AllocateContainerID allocates a new container ID
func AllocateContainerID() int {
allocationMu.Lock()
defer allocationMu.Unlock()
id := AppConfig.NextContainerID
AppConfig.NextContainerID++
SaveConfig()
@@ -1158,6 +1613,23 @@ func UpdateContainerStatus(id int, status string) {
}
}
func UpdateContainerStatusAndRestore(id int, status string, restoreOnHostBoot bool) {
c := FindContainer(id)
if c != nil {
c.Status = status
c.RestoreOnHostBoot = restoreOnHostBoot
SaveConfig()
}
}
func SetContainerRestoreOnHostBoot(id int, restore bool) {
c := FindContainer(id)
if c != nil {
c.RestoreOnHostBoot = restore
SaveConfig()
}
}
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
c := FindContainer(id)
if c == nil {
@@ -1256,6 +1728,8 @@ func normalizeNATPortRangeDefaults() bool {
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
func AllocateSSHPort() (int, error) {
allocationMu.Lock()
defer allocationMu.Unlock()
used := collectAllHostPorts()
start, end := NATPortRange()
port := AppConfig.NextSSHPort
+114
View File
@@ -0,0 +1,114 @@
package config
import (
"path/filepath"
"testing"
)
func TestNormalizeStoragePoolsReplacesPersistedCustomPath(t *testing.T) {
previousConfig := AppConfig
t.Cleanup(func() { AppConfig = previousConfig })
mountPoint := filepath.Join(t.TempDir(), "data")
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
ID: "data",
Name: "data",
Path: filepath.Join(t.TempDir(), "uncontrolled"),
MountPoint: mountPoint,
Enabled: true,
}}}
if !normalizeStoragePools() {
t.Fatal("expected custom path normalization to report a change")
}
want := managedStoragePoolPath(mountPoint)
if got := AppConfig.StoragePools[0].Path; got != want {
t.Fatalf("normalized path = %q, want %q", got, want)
}
}
func TestSelectStoragePoolForContent(t *testing.T) {
previousConfig := AppConfig
previousProbe := probeStoragePoolFreeBytes
t.Cleanup(func() {
AppConfig = previousConfig
probeStoragePoolFreeBytes = previousProbe
})
AppConfig = &ClicdConfig{StoragePools: []StoragePool{
{
ID: "primary",
Path: "/primary",
ContentTypes: []string{StorageContentLXC},
DefaultContents: []string{StorageContentLXC},
Enabled: true,
},
{
ID: "large",
Path: "/large",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
},
{
ID: "small",
Path: "/small",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
},
}}
free := map[string]int64{
"primary": 20 * 1024 * 1024 * 1024,
"large": 50 * 1024 * 1024 * 1024,
"small": 10 * 1024 * 1024 * 1024,
}
probeStoragePoolFreeBytes = func(pool StoragePool) (int64, bool) {
value, ok := free[pool.ID]
return value, ok
}
pool, err := SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "primary" {
t.Fatalf("selected %q, want configured default primary", pool.ID)
}
free["primary"] = 128 * 1024 * 1024
pool, err = SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "large" {
t.Fatalf("selected %q, want largest fallback pool", pool.ID)
}
pool, err = SelectStoragePoolForContent(StorageContentLXC, "small", 5*1024*1024*1024)
if err != nil {
t.Fatal(err)
}
if pool.ID != "small" {
t.Fatalf("selected %q, want requested pool", pool.ID)
}
}
func TestSelectStoragePoolRequiresEnabledContent(t *testing.T) {
previousConfig := AppConfig
previousProbe := probeStoragePoolFreeBytes
t.Cleanup(func() {
AppConfig = previousConfig
probeStoragePoolFreeBytes = previousProbe
})
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
ID: "primary",
Path: "/primary",
ContentTypes: []string{StorageContentLXC},
Enabled: true,
}}}
probeStoragePoolFreeBytes = func(StoragePool) (int64, bool) { return 100 * 1024 * 1024 * 1024, true }
if _, err := SelectStoragePoolForContent(StorageContentSnapshots, "", 0); err == nil {
t.Fatal("expected snapshots selection to fail when no pool enables snapshots")
}
}
+177 -64
View File
@@ -20,37 +20,45 @@ var (
)
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"`
NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_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"`
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
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"`
NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_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"`
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
LANInterface string `json:"lan_interface,omitempty"`
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"`
}
func parseSavedTaskConfig(raw string) savedTaskConfig {
@@ -183,6 +191,8 @@ func ensureSchema() error {
lxc_name TEXT,
kvm_name TEXT,
disk_image TEXT,
storage_pool_id TEXT,
storage_path TEXT,
mac_address TEXT,
template TEXT,
vcpu REAL,
@@ -202,7 +212,13 @@ func ensureSchema() error {
io_read_mbps INTEGER NOT NULL DEFAULT 0,
io_write_mbps INTEGER NOT NULL DEFAULT 0,
status TEXT,
restore_on_host_boot INTEGER NOT NULL DEFAULT 0,
ip TEXT,
lan_ipv4_mode TEXT,
lan_interface TEXT,
lan_ipv4_address TEXT,
lan_ipv4_prefix_len INTEGER,
lan_ipv4_gateway TEXT,
ipv6 TEXT,
ipv6_prefix_len INTEGER,
ipv6_interface TEXT,
@@ -222,7 +238,9 @@ func ensureSchema() error {
snapshot_schedule_created_by TEXT,
policy_blocked INTEGER,
policy_blocked_reason TEXT,
policy_blocked_at TEXT
policy_blocked_at TEXT,
allowed_image_ids TEXT,
image_limit_configured INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS port_mappings (
container_id INTEGER NOT NULL,
@@ -258,7 +276,9 @@ func ensureSchema() error {
pass_hash TEXT,
access_code TEXT,
created_at TEXT,
token_version INTEGER
token_version INTEGER,
allowed_image_ids TEXT,
image_limit_configured INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
sub_user_id TEXT NOT NULL,
@@ -338,6 +358,11 @@ func ensureSchema() error {
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
cfg_port_mapping_count INTEGER,
cfg_assign_nat INTEGER,
cfg_lan_ipv4_mode TEXT,
cfg_lan_interface TEXT,
cfg_lan_ipv4_address TEXT,
cfg_lan_ipv4_prefix_len INTEGER,
cfg_lan_ipv4_gateway TEXT,
cfg_snapshot_limit INTEGER,
cfg_assign_ipv4 INTEGER,
cfg_ipv4_count INTEGER,
@@ -348,6 +373,8 @@ func ensureSchema() error {
cfg_ssh_auth_mode TEXT,
cfg_ssh_password TEXT,
cfg_ssh_public_key TEXT,
cfg_allowed_image_ids TEXT,
cfg_image_limit_configured INTEGER NOT NULL DEFAULT 0,
cfg_expires_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS task_extra_ports (
@@ -410,14 +437,23 @@ func ensureSchemaMigrations() error {
{"tasks", "cfg_ipv4_count", "INTEGER"},
{"tasks", "cfg_public_ipv4s", "TEXT"},
{"tasks", "cfg_assign_nat", "INTEGER"},
{"tasks", "cfg_lan_ipv4_mode", "TEXT"},
{"tasks", "cfg_lan_interface", "TEXT"},
{"tasks", "cfg_lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
{"tasks", "cfg_lan_ipv4_prefix_len", "INTEGER NOT NULL DEFAULT 0"},
{"tasks", "cfg_lan_ipv4_gateway", "TEXT NOT NULL DEFAULT ''"},
{"tasks", "cfg_ipv6_count", "INTEGER"},
{"tasks", "cfg_ipv6_addresses", "TEXT"},
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
{"tasks", "cfg_ssh_password", "TEXT"},
{"tasks", "cfg_ssh_public_key", "TEXT"},
{"tasks", "cfg_allowed_image_ids", "TEXT"},
{"tasks", "cfg_image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
{"port_mappings", "host_ip", "TEXT"},
{"container_public_ipv4s", "prefix_len", "INTEGER"},
{"container_public_ipv4s", "gateway", "TEXT"},
{"sub_users", "allowed_image_ids", "TEXT"},
{"sub_users", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
@@ -425,6 +461,16 @@ func ensureSchemaMigrations() error {
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
{"containers", "firewall_rules", "TEXT"},
{"containers", "allowed_image_ids", "TEXT"},
{"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "restore_on_host_boot", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "storage_pool_id", "TEXT"},
{"containers", "storage_path", "TEXT"},
{"containers", "lan_ipv4_mode", "TEXT"},
{"containers", "lan_interface", "TEXT"},
{"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
{"containers", "lan_ipv4_prefix_len", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "lan_ipv4_gateway", "TEXT NOT NULL DEFAULT ''"},
} {
wasAdded, err := ensureColumn(column.table, column.name, column.def)
if err != nil {
@@ -466,6 +512,27 @@ func ensureSchemaMigrations() error {
return err
}
}
if _, err := db.Exec(`UPDATE containers
SET lan_ipv4_mode = COALESCE(lan_ipv4_mode, ''),
lan_interface = COALESCE(lan_interface, ''),
lan_ipv4_address = COALESCE(lan_ipv4_address, ''),
lan_ipv4_prefix_len = COALESCE(lan_ipv4_prefix_len, 0),
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
return err
}
if _, err := db.Exec(`UPDATE containers
SET storage_pool_id = COALESCE(storage_pool_id, ''),
storage_path = COALESCE(storage_path, '')`); err != nil {
return err
}
if _, err := db.Exec(`UPDATE tasks
SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''),
cfg_lan_interface = COALESCE(cfg_lan_interface, ''),
cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''),
cfg_lan_ipv4_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0),
cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
return err
}
return nil
}
@@ -528,6 +595,7 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
NATPortEnd: atoi(meta["nat_port_end"]),
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
TaskConcurrency: atoi(meta["task_concurrency"]),
Language: meta["language"],
}
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
@@ -545,6 +613,9 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
}
if raw := strings.TrimSpace(meta["storage_pools"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.StoragePools)
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
@@ -644,6 +715,7 @@ func saveMeta(tx *sql.Tx) error {
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools)
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
@@ -657,12 +729,14 @@ func saveMeta(tx *sql.Tx) error {
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency),
"language": NormalizeLanguage(AppConfig.Language),
"ssl": string(sslJSON),
"ssl_certificates": string(sslCertificatesJSON),
"public_ipv4_pool": string(publicIPv4PoolJSON),
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
"storage_pools": string(storagePoolsJSON),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
@@ -677,30 +751,33 @@ func saveMeta(tx *sql.Tx) error {
func saveContainers(tx *sql.Tx) error {
for _, c := range AppConfig.Containers {
NormalizeContainerResourceAliases(&c)
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
if _, err := tx.Exec(`INSERT INTO containers (
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
io_speed_mbps, io_read_mbps, io_write_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
status, restore_on_host_boot, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
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,
firewall_enabled, firewall_default_action, firewall_rules
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.StoragePoolID, c.StoragePath, c.MACAddress, c.Template,
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
c.Status, boolInt(c.RestoreOnHostBoot), c.IP, c.LANIPv4Mode, c.LANInterface, c.LANIPv4Address, c.LANIPv4PrefixLen, c.LANIPv4Gateway,
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,
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules),
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules), allowedImageIDs, boolInt(c.ImageLimitConfigured),
); err != nil {
return err
}
@@ -728,8 +805,9 @@ func saveContainers(tx *sql.Tx) error {
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 {
allowedImageIDs := encodeStringSlice(su.AllowedImageIDs)
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version, allowed_image_ids, image_limit_configured)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion, allowedImageIDs, boolInt(su.ImageLimitConfigured)); err != nil {
return err
}
for i, name := range su.ContainerNames {
@@ -838,19 +916,21 @@ func saveTasksDB(tx *sql.Tx) error {
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps,
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.LANIPv4Mode, cfg.LANInterface,
cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway, cfg.SnapshotLimit,
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, encodeStringSlice(cfg.AllowedImageIDs), boolInt(cfg.ImageLimitConfigured), cfg.ExpiresAt,
); err != nil {
return err
}
@@ -894,17 +974,18 @@ func saveSnapshots(tx *sql.Tx) error {
func loadContainers() ([]Container, error) {
rows, err := db.Query(`SELECT
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
io_speed_mbps, io_read_mbps, io_write_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
status, restore_on_host_boot, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
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,
firewall_enabled, firewall_default_action, firewall_rules
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
FROM containers ORDER BY id`)
if err != nil {
return nil, err
@@ -914,31 +995,48 @@ func loadContainers() ([]Container, error) {
result := []Container{}
for rows.Next() {
var c Container
var scheduleEnabled, policyBlocked, firewallEnabled int
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured, restoreOnHostBoot int
var firewallDefaultAction string
var firewallRulesJSON sql.NullString
var firewallRulesJSON, allowedImageIDs sql.NullString
var storagePoolID, storagePath sql.NullString
var lanIPv4Mode, lanInterface sql.NullString
var lanIPv4Address, lanIPv4Gateway sql.NullString
var lanIPv4PrefixLen sql.NullInt64
if err := rows.Scan(
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &storagePoolID, &storagePath, &c.MACAddress, &c.Template,
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
&c.Status, &restoreOnHostBoot, &c.IP, &lanIPv4Mode, &lanInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway,
&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,
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON,
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, &allowedImageIDs, &imageLimitConfigured,
); err != nil {
return nil, err
}
c.StoragePoolID = storagePoolID.String
c.StoragePath = storagePath.String
c.LANIPv4Mode = lanIPv4Mode.String
c.LANInterface = lanInterface.String
c.LANIPv4Address = lanIPv4Address.String
if lanIPv4PrefixLen.Valid {
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
}
c.LANIPv4Gateway = lanIPv4Gateway.String
c.SnapshotScheduleEnabled = scheduleEnabled != 0
c.RestoreOnHostBoot = restoreOnHostBoot != 0
c.PolicyBlocked = policyBlocked != 0
c.FirewallEnabled = firewallEnabled != 0
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
c.ImageLimitConfigured = imageLimitConfigured != 0
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
}
c.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
NormalizeContainerResourceAliases(&c)
result = append(result, c)
}
@@ -1034,7 +1132,7 @@ func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
}
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`)
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version, allowed_image_ids, image_limit_configured FROM sub_users ORDER BY created_at, id`)
if err != nil {
return nil, err
}
@@ -1042,9 +1140,13 @@ func loadSubUsers() ([]SubUser, error) {
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 {
var allowedImageIDs sql.NullString
var imageLimitConfigured int
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion, &allowedImageIDs, &imageLimitConfigured); err != nil {
return nil, err
}
su.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
su.ImageLimitConfigured = imageLimitConfigured != 0
result = append(result, su)
}
if err := rows.Err(); err != nil {
@@ -1136,9 +1238,10 @@ func loadTasks() ([]SavedTask, error) {
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
FROM tasks ORDER BY created_at, id`)
if err != nil {
return nil, err
@@ -1149,19 +1252,20 @@ func loadTasks() ([]SavedTask, error) {
for rows.Next() {
var t SavedTask
var cfg savedTaskConfig
var assignIPv4, assignIPv6 int
var assignIPv4, assignIPv6, imageLimitConfigured int
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
var lanIPv4Mode, lanInterface, lanIPv4Address, lanIPv4Gateway, sshAuthMode, sshPassword, sshPublicKey, allowedImageIDs sql.NullString
var assignNAT, lanIPv4PrefixLen, ipv4Count, ipv6Count sql.NullInt64
if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
&cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
&cfg.PortMappingCount, &assignNAT, &lanIPv4Mode, &lanInterface,
&lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, &cfg.SnapshotLimit,
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
&sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt,
); err != nil {
return nil, err
}
@@ -1171,6 +1275,13 @@ func loadTasks() ([]SavedTask, error) {
value := assignNAT.Int64 != 0
cfg.AssignNAT = &value
}
cfg.LANIPv4Mode = lanIPv4Mode.String
cfg.LANInterface = lanInterface.String
cfg.LANIPv4Address = lanIPv4Address.String
if lanIPv4PrefixLen.Valid {
cfg.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
}
cfg.LANIPv4Gateway = lanIPv4Gateway.String
cfg.AssignIPv4 = assignIPv4 != 0
if ipv4Count.Valid {
cfg.IPv4Count = int(ipv4Count.Int64)
@@ -1184,6 +1295,8 @@ func loadTasks() ([]SavedTask, error) {
cfg.SSHAuthMode = sshAuthMode.String
cfg.SSHPassword = sshPassword.String
cfg.SSHPublicKey = sshPublicKey.String
cfg.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
cfg.ImageLimitConfigured = imageLimitConfigured != 0
normalizeSavedTaskConfigLimits(&cfg)
result = append(result, t)
configs = append(configs, cfg)
@@ -93,11 +93,15 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
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 cfg.TaskConcurrency != DefaultTaskConcurrency {
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
}
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"
cfg.TaskConcurrency = 6
if err := SaveConfig(); err != nil {
t.Fatal(err)
}
@@ -111,6 +115,9 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
if got := cfg.Containers[0].Status; got != "stopped" {
t.Fatalf("expected sqlite value to win after migration, got %q", got)
}
if got := cfg.TaskConcurrency; got != 6 {
t.Fatalf("persisted task concurrency = %d, want 6", got)
}
}
func resetConfigStoreForTest(t *testing.T) {
+246 -28
View File
@@ -95,6 +95,9 @@ var (
)
func BaseDir() string {
if pool := config.PreferredStoragePoolForContent(config.StorageContentKVM); pool != nil {
return filepath.Join(pool.Path, "kvm")
}
return "/var/lib/clicd/kvm"
}
@@ -102,11 +105,30 @@ func NewManager() *Manager {
return &Manager{BasePath: BaseDir()}
}
func NewManagerForStoragePool(poolID string) *Manager {
if pool := config.StoragePoolByID(poolID); pool != nil && pool.Enabled {
for _, content := range pool.ContentTypes {
if content == config.StorageContentKVM {
return &Manager{BasePath: filepath.Join(pool.Path, "kvm")}
}
}
}
return NewManager()
}
func (m *Manager) instancesDir() string {
return filepath.Join(m.BasePath, "instances")
}
func (m *Manager) instanceDir(name string) string {
if config.AppConfig != nil {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.IsKVM() && c.VirshName() == name && strings.TrimSpace(c.DiskImage) != "" {
return filepath.Dir(c.DiskImage)
}
}
}
return filepath.Join(m.instancesDir(), name)
}
@@ -135,10 +157,19 @@ func DownloadImage(image Image) error {
}
func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error {
if err := os.MkdirAll(CacheDir(), 0755); err != nil {
pool, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024)
if err != nil {
return err
}
target := ImagePath(image.ID)
cacheDir := filepath.Join(pool.Path, "images", "kvm")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
ext := ".qcow2"
if image.Distro == "windows" {
ext = ".iso"
}
target := filepath.Join(cacheDir, image.ID+ext)
if ok, _ := ImageDownloadedInfo(image.ID); ok {
return nil
}
@@ -348,6 +379,7 @@ func normalizeQCOW2(ctx context.Context, src, target string) error {
func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
cfg.NormalizeResourceAliases()
cfg.ReportProgress("preparing", "检查 KVM 镜像与创建参数")
image := FindImage(cfg.TemplateID)
if image == nil {
return fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
@@ -367,6 +399,22 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
}
if IsWindowsImage(image.ID) && cfg.DiskGB < 30 {
cfg.DiskGB = 30
} else if image.Desktop != "" && cfg.DiskGB < 20 {
cfg.DiskGB = 20
}
cfg.ReportProgress("storage", "选择虚拟机存储磁盘")
pool, err := config.SelectStoragePoolForContent(
config.StorageContentKVM,
cfg.StoragePoolID,
int64(cfg.DiskGB)*1024*1024*1024,
)
if err != nil {
return err
}
cfg.StoragePoolID = pool.ID
m = NewManagerForStoragePool(pool.ID)
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
@@ -376,6 +424,10 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
if !cfg.ImageLimitConfigured && len(cfg.AllowedImageIDs) == 0 && cfg.TemplateID != "" {
cfg.AllowedImageIDs = []string{cfg.TemplateID}
cfg.ImageLimitConfigured = true
}
id := config.AllocateContainerID()
vmName := fmt.Sprintf("vm-%d", id)
@@ -420,6 +472,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
sshPublicKey = sshAccess.PublicKey
sshAuthMode = sshAccess.Mode
}
cfg.ReportProgress("addresses", "分配 IPv4 与 IPv6 地址")
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
return nil, err
@@ -447,6 +500,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if cfg.DiskGB < 30 {
cfg.DiskGB = 30
}
cfg.ReportProgress("disk", "创建 Windows 虚拟磁盘")
if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil {
return nil, err
}
@@ -455,6 +509,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
winAdminPassword = generateWindowsPassword()
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
cfg.ReportProgress("cloud_init", "生成 Windows 自动应答配置")
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
return nil, err
}
@@ -468,9 +523,11 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
cfg.DiskGB = 20
}
}
cfg.ReportProgress("disk", "创建 KVM 系统磁盘")
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err
}
cfg.ReportProgress("cloud_init", "生成 cloud-init 初始化配置")
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil {
return nil, err
}
@@ -480,6 +537,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
return nil, err
}
cfg.ReportProgress("define", "注册 KVM 虚拟机")
cmd := exec.Command("virsh", "define", xmlPath)
if output, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("virsh define failed: %v, output: %s", err, string(output))
@@ -488,6 +546,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
sshPort := 0
portMappings := []config.PortMapping{}
if allocatePorts && cfg.WantsNAT() {
cfg.ReportProgress("nat", "分配并配置 NAT 端口")
sshPort, err = config.AllocateSSHPort()
if err != nil {
return nil, err
@@ -534,6 +593,12 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if trafficMode == "" {
trafficMode = "total"
}
storagePoolID := cfg.StoragePoolID
if storagePoolID == "" {
if pool := config.DefaultStoragePoolForContent(config.StorageContentKVM); pool != nil {
storagePoolID = pool.ID
}
}
container := &config.Container{
ID: id,
UUID: config.NewContainerUUID(),
@@ -541,6 +606,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
Virtualization: config.VirtualizationKVM,
KVMName: vmName,
DiskImage: diskPath,
StoragePoolID: storagePoolID,
StoragePath: m.instanceDir(vmName),
MACAddress: mac,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
@@ -567,13 +634,16 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
return sshPassword
}(),
PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount,
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
ImageLimitConfigured: cfg.ImageLimitConfigured,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
}
container.NormalizeNetworkAssignments()
cfg.ReportProgress("metadata", "保存虚拟机配置")
return container, nil
}
@@ -643,7 +713,7 @@ func (m *Manager) StartContainer(id int) error {
if !isWindows && c.IP != "" {
m.waitForCloudInitReady(name, c.IP, c.SSHPassword)
}
config.UpdateContainerStatus(id, "running")
config.UpdateContainerStatusAndRestore(id, "running", true)
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.applyIPv6Runtime(c); err != nil {
return err
@@ -722,13 +792,13 @@ func (m *Manager) StopContainer(id int) error {
name := c.VirshName()
status, _ := m.GetContainerStatus(name)
if status != "running" {
config.UpdateContainerStatus(id, "stopped")
config.UpdateContainerStatusAndRestore(id, "stopped", false)
return nil
}
exec.Command("virsh", "shutdown", name).Run()
for i := 0; i < 20; i++ {
if status, _ := m.GetContainerStatus(name); status != "running" {
config.UpdateContainerStatus(id, "stopped")
config.UpdateContainerStatusAndRestore(id, "stopped", false)
return nil
}
time.Sleep(1 * time.Second)
@@ -737,7 +807,7 @@ func (m *Manager) StopContainer(id int) error {
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("virsh destroy failed: %v, output: %s", err, string(output))
}
config.UpdateContainerStatus(id, "stopped")
config.UpdateContainerStatusAndRestore(id, "stopped", false)
return nil
}
@@ -948,7 +1018,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
return nil
}
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
kvmSnapshotMu.Lock()
defer kvmSnapshotMu.Unlock()
@@ -974,17 +1044,26 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
name := c.VirshName()
instanceDir := m.instanceDir(name)
if err := safePathUnder(instanceDir, m.instancesDir()); err != nil {
if err := safePathUnder(instanceDir, filepath.Dir(instanceDir)); err != nil {
return config.Snapshot{}, err
}
if _, err := os.Stat(instanceDir); err != nil {
return config.Snapshot{}, fmt.Errorf("VM storage not found: %v", err)
}
pool, err := config.SelectStoragePoolForContent(
config.StorageContentSnapshots,
firstString(storagePoolID),
dirSizeBytes(instanceDir),
)
if err != nil {
return config.Snapshot{}, err
}
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
snapshotDir := filepath.Join(snapshotBaseDir(), "kvm", strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
baseDir := filepath.Join(pool.Path, "snapshots")
snapshotDir := filepath.Join(baseDir, "kvm", strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, baseDir); err != nil {
return config.Snapshot{}, err
}
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
@@ -1037,7 +1116,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
if snapshot.Path != "" {
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if err := os.RemoveAll(snapshot.Path); err != nil {
@@ -1059,7 +1138,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
if snapshot.Path == "" {
return fmt.Errorf("snapshot path is empty")
}
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if _, err := os.Stat(snapshot.Path); err != nil {
@@ -1075,7 +1154,8 @@ func (m *Manager) RestoreSnapshot(id string) error {
}
name := c.VirshName()
instanceDir := m.instanceDir(name)
if err := safePathUnder(instanceDir, m.instancesDir()); err != nil {
instanceParent := filepath.Dir(instanceDir)
if err := safePathUnder(instanceDir, instanceParent); err != nil {
return err
}
@@ -1083,8 +1163,8 @@ func (m *Manager) RestoreSnapshot(id string) error {
if err != nil {
return err
}
backupDir := filepath.Join(m.instancesDir(), fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
if err := safePathUnder(backupDir, m.instancesDir()); err != nil {
backupDir := filepath.Join(instanceParent, fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
if err := safePathUnder(backupDir, instanceParent); err != nil {
return err
}
if err := os.Rename(instanceDir, backupDir); err != nil && !os.IsNotExist(err) {
@@ -1104,6 +1184,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
return fmt.Errorf("virsh define failed after restore: %v, output: %s", err, string(output))
}
c.DiskImage = filepath.Join(instanceDir, "disk.qcow2")
c.StoragePath = instanceDir
c.Status = "stopped"
c.IP = ""
config.SaveConfig()
@@ -1238,7 +1319,33 @@ func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) tim
}
func snapshotBaseDir() string {
return filepath.Join(config.AppConfig.DataDir, "snapshots")
return snapshotBaseDirForPool("")
}
func snapshotBaseDirForPool(poolID string) string {
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
return filepath.Join(pool.Path, "snapshots")
}
return ""
}
func safeSnapshotPath(path string) error {
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
return nil
}
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
return nil
}
}
return fmt.Errorf("unsafe snapshot path: %s", path)
}
func firstString(values []string) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[0])
}
func copyTree(src string, dst string) error {
@@ -1617,12 +1724,12 @@ func ensureDefaultNetwork() error {
// Ensure libvirtd is running
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
// Non-systemd systems may use a different init, try virsh connect
if exec.Command("virsh", "connect").Run() != nil {
if virshCLocaleCommand("connect").Run() != nil {
return fmt.Errorf("libvirtd is not running and could not be started")
}
}
// Ensure default network is defined
if exec.Command("virsh", "net-info", "default").Run() != nil {
if virshCLocaleCommand("net-info", "default").Run() != nil {
// Default network may not be defined; try to define it
netXML := `<network>
<name>default</name>
@@ -1639,7 +1746,7 @@ func ensureDefaultNetwork() error {
return fmt.Errorf("failed to write default network XML: %v", err)
}
defer os.Remove(tmpFile)
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
if out, err := virshCLocaleCommand("net-define", tmpFile).CombinedOutput(); err != nil {
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
}
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
@@ -1647,19 +1754,27 @@ func ensureDefaultNetwork() error {
}
}
// Start and autostart the default network
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
if out, err := virshCLocaleCommand("net-info", "default").Output(); err == nil {
if !libvirtNetworkActive(string(out)) {
if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil {
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
if startOut, startErr := virshCLocaleCommand("net-start", "default").CombinedOutput(); startErr != nil {
if verifyOut, verifyErr := virshCLocaleCommand("net-info", "default").Output(); verifyErr != nil || !libvirtNetworkActive(string(verifyOut)) {
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
}
}
}
}
if out, err := exec.Command("virsh", "net-autostart", "default").CombinedOutput(); err != nil {
if out, err := virshCLocaleCommand("net-autostart", "default").CombinedOutput(); err != nil {
return fmt.Errorf("failed to set autostart for libvirt default network: %v, output: %s", err, string(out))
}
return nil
}
func virshCLocaleCommand(args ...string) *exec.Cmd {
cmd := exec.Command("virsh", args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LC_MESSAGES=C", "LANG=C", "LANGUAGE=C")
return cmd
}
func libvirtNetworkActive(info string) bool {
for _, line := range strings.Split(info, "\n") {
key, value, ok := strings.Cut(line, ":")
@@ -3357,6 +3472,109 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
return c, nil
}
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if !c.IsKVM() {
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
}
assignments := []config.PublicIPv4Assignment{}
if auto || len(requested) > 0 {
allocated, err := lxc.AllocatePublicIPv4Assignments(id, requested, count, auto)
if err != nil {
return nil, err
}
assignments = allocated
}
c.PublicIPv4s = assignments
reconcileKVMPortMappingHostIPs(c)
c.NormalizeNetworkAssignments()
config.SaveConfig()
lxcManager := lxc.NewManager()
_ = lxcManager.CleanPortMappings(id)
lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s)
if c.Status == "running" && c.IP != "" {
if err := lxcManager.ApplyPortMappings(id); err != nil {
return nil, err
}
}
return c, nil
}
func reconcileKVMPortMappingHostIPs(c *config.Container) {
if c == nil {
return
}
assigned := map[string]bool{}
for _, item := range c.PublicIPv4s {
if addr := strings.TrimSpace(item.Address); addr != "" {
assigned[addr] = true
}
}
replacement := ""
if len(assigned) == 1 {
for addr := range assigned {
replacement = addr
}
}
for i := range c.PortMappings {
hostIP := strings.TrimSpace(c.PortMappings[i].HostIP)
if hostIP == "" || assigned[hostIP] {
continue
}
c.PortMappings[i].HostIP = replacement
}
}
func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if !c.IsKVM() {
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
}
old := *c
old.IPv6Addresses = append([]config.IPv6Assignment(nil), c.IPv6Addresses...)
removeKVMIPv6Runtime(&old)
assignments := []config.IPv6Assignment{}
if auto || len(requested) > 0 {
allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto)
if err != nil {
if old.IPv6 != "" || len(old.IPv6Addresses) > 0 {
_ = m.applyIPv6Runtime(&old)
}
return nil, err
}
assignments = allocated
}
c.IPv6 = ""
c.IPv6PrefixLen = 0
c.IPv6Interface = ""
c.IPv6Addresses = assignments
c.NormalizeNetworkAssignments()
config.SaveConfig()
if len(c.IPv6Addresses) > 0 {
if err := m.applyIPv6Runtime(c); err != nil {
return nil, err
}
} else if c.Status == "running" {
if err := lxc.ApplyFirewallRules(c.ID); err != nil {
fmt.Printf("Warning: failed to re-apply firewall rules after KVM IPv6 removal for %s: %v\n", c.Name, err)
}
}
return c, nil
}
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
+34
View File
@@ -3,6 +3,7 @@ package kvm
import (
"crypto/ed25519"
"crypto/rand"
"path/filepath"
"reflect"
"testing"
@@ -11,6 +12,39 @@ import (
"golang.org/x/crypto/ssh"
)
func TestImagePathUsesAllowlistedImageID(t *testing.T) {
for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute", "unknown-image"} {
if got := filepath.Base(ImagePath(id)); got != "__invalid_image_id__.qcow2" {
t.Fatalf("ImagePath(%q) basename = %q", id, got)
}
}
validID := GetImages()[0].ID
if got := filepath.Base(ImagePath(validID)); got != validID+".qcow2" {
t.Fatalf("ImagePath(%q) basename = %q", validID, got)
}
}
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
tests := []struct {
name string
info string
want bool
}{
{name: "active", info: "Name: default\nActive: yes\n", want: true},
{name: "spacing and case", info: " Active : YES \r\n", want: true},
{name: "inactive", info: "Name: default\nActive: no\n", want: false},
{name: "missing field", info: "Name: default\nAutostart: yes\n", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := libvirtNetworkActive(tc.info); got != tc.want {
t.Fatalf("libvirtNetworkActive(%q) = %v, want %v", tc.info, got, tc.want)
}
})
}
}
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
password := `pa'";$(touch /tmp/pwned); echo #\\word`
got, err := chpasswdStdin("root", password)
+41 -1
View File
@@ -1,8 +1,11 @@
package kvm
import (
"os"
"path/filepath"
"runtime"
"clicd/internal/config"
)
type Image struct {
@@ -46,12 +49,25 @@ func amd64Images() []Image {
Description: "Ubuntu 22.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
},
{
ID: "kvm-debian-trixie", Name: "Debian 13 KVM",
Distro: "debian", Release: "trixie", Arch: "amd64",
Description: "Debian 13 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2",
},
{
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
},
{
ID: "kvm-debian-trixie-xfce", Name: "Debian 13 XFCE KVM",
Distro: "debian", Release: "trixie", Arch: "amd64",
Description: "Debian 13 generic cloud image with XFCE desktop provisioned via cloud-init",
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2",
Desktop: "xfce",
},
{
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64",
@@ -118,6 +134,12 @@ func arm64Images() []Image {
Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM",
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
},
{
ID: "kvm-debian-trixie", Name: "Debian 13 KVM",
Distro: "debian", Release: "trixie", Arch: "arm64",
Description: "Debian 13 generic cloud image for ARM64 KVM",
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2",
},
{
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
Distro: "debian", Release: "bookworm", Arch: "arm64",
@@ -161,16 +183,34 @@ func FindImage(id string) *Image {
}
func CacheDir() string {
if pool := config.PreferredStoragePoolForContent(config.StorageContentImages); pool != nil {
return filepath.Join(pool.Path, "images", "kvm")
}
return filepath.Join(BaseDir(), "images")
}
func ImagePath(id string) string {
img := FindImage(id)
ext := ".qcow2"
safeID := "__invalid_image_id__"
if img != nil {
safeID = img.ID
}
if img != nil && img.Distro == "windows" {
ext = ".iso"
}
return filepath.Join(CacheDir(), id+ext)
fileName := safeID + ext
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
candidate := filepath.Join(pool.Path, "images", "kvm", fileName)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
legacy := filepath.Join("/var/lib/clicd/kvm/images", fileName)
if info, err := os.Stat(legacy); err == nil && !info.IsDir() {
return legacy
}
return filepath.Join(CacheDir(), fileName)
}
// IsWindowsImage returns true if the image distro is "windows".
+103
View File
@@ -1515,6 +1515,75 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
return c, nil
}
func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
oldAssignments := append([]config.IPv6Assignment(nil), c.IPv6Addresses...)
oldPrimary := c.IPv6
oldPrimaryPrefixLen := c.IPv6PrefixLen
oldPrimaryInterface := c.IPv6Interface
assignments := []config.IPv6Assignment{}
if auto || len(requested) > 0 {
allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto)
if err != nil {
return nil, err
}
assignments = allocated
}
for _, assignment := range oldAssignments {
uplink := assignment.Interface
if uplink == "" {
uplink = oldPrimaryInterface
}
removeHostIPv6Routing(assignment.Address, uplink)
}
if len(oldAssignments) == 0 && oldPrimary != "" {
removeHostIPv6Routing(oldPrimary, oldPrimaryInterface)
oldAssignments = append(oldAssignments, config.IPv6Assignment{Address: oldPrimary, PrefixLen: oldPrimaryPrefixLen, Interface: oldPrimaryInterface})
}
c.IPv6 = ""
c.IPv6PrefixLen = 0
c.IPv6Interface = ""
c.IPv6Addresses = assignments
c.NormalizeNetworkAssignments()
config.SaveConfig()
if err := m.applyIPv6Config(c.LxcName(), c.IPv6AddressStrings()...); err != nil {
return nil, err
}
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
if _, err := os.Stat(rootfsPath); err == nil {
if len(c.IPv6Addresses) == 0 {
if err := removeContainerIPv6Init(rootfsPath); err != nil {
fmt.Printf("Warning: failed to remove IPv6 init in %s: %v\n", c.LxcName(), err)
}
} else if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
}
}
status, _ := m.GetContainerStatus(c.LxcName())
if status == "running" {
m.removeGuestIPv6Addresses(c.LxcName(), oldAssignments)
}
if len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil {
return nil, err
}
} else if status == "running" {
m.removeGuestIPv6DefaultRoute(c.LxcName())
if err := ApplyFirewallRules(c.ID); err != nil {
fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 removal for %s: %v\n", c.Name, err)
}
}
return c, nil
}
func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
configFile := filepath.Join(m.LxcPath, lxcName, "config")
data, err := os.ReadFile(configFile)
@@ -1705,6 +1774,25 @@ exit 0
return nil
}
func removeContainerIPv6Init(rootfsPath string) error {
paths := []string{
filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init"),
filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service"),
filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants", "clicd-ipv6.service"),
filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6"),
filepath.Join(rootfsPath, "etc", "runlevels", "default", "clicd-ipv6"),
}
for _, level := range []string{"2", "3", "4", "5"} {
paths = append(paths, filepath.Join(rootfsPath, "etc", "rc"+level+".d", "S99clicd-ipv6"))
}
for _, path := range paths {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func installContainerIPv6Systemd(rootfsPath string) error {
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
@@ -1873,6 +1961,21 @@ func containerIPv6ConnectivityOK(lxcName string) bool {
return false
}
func (m *Manager) removeGuestIPv6Addresses(lxcName string, assignments []config.IPv6Assignment) {
addrs := ipv6AssignmentAddresses(assignments)
if len(addrs) == 0 {
return
}
quoted := shellQuotedIPv6List(addrs)
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
fmt.Sprintf("for ip in %s; do ip -6 addr del \"$ip/128\" dev eth0 2>/dev/null || true; done", quoted)).Run()
}
func (m *Manager) removeGuestIPv6DefaultRoute(lxcName string) {
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
fmt.Sprintf("ip -6 route del default via %s dev eth0 2>/dev/null || true", shellQuote(ipv6GatewayLinkLocal))).Run()
}
func ensureIPv6NAT66(ipv6, uplink string) {
if ipv6 == "" || uplink == "" {
return
+619 -85
View File
@@ -8,6 +8,8 @@ import (
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"net/netip"
"os"
"os/exec"
"path/filepath"
@@ -16,6 +18,7 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
"clicd/internal/config"
@@ -81,6 +84,13 @@ func (m *Manager) WarmRunningContainersSSH() {
continue
}
config.UpdateContainerStatus(c.ID, "running")
if current := config.FindContainer(c.ID); current != nil {
m.refreshContainerIPv4Details(current)
c = *current
}
if err := m.ensureLANHostAccess(&c); err != nil {
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", c.LxcName(), err)
}
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
continue
}
@@ -218,37 +228,53 @@ func NewManager() *Manager {
// ContainerConfig defines container creation parameters
type ContainerConfig 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"`
NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
IOSpeedMBps int `json:"io_speed_mbps"`
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
StoragePoolID string `json:"storage_pool_id,omitempty"`
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"`
NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
IOSpeedMBps int `json:"io_speed_mbps"`
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
LANInterface string `json:"lan_interface,omitempty"`
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"`
Progress func(stage, detail string) `json:"-"`
}
// ReportProgress reports a best-effort creation phase to the task queue.
func (cfg ContainerConfig) ReportProgress(stage, detail string) {
if cfg.Progress != nil {
cfg.Progress(stage, detail)
}
}
func (cfg *ContainerConfig) NormalizeResourceAliases() {
@@ -287,12 +313,28 @@ func (cfg *ContainerConfig) NormalizeResourceAliases() {
}
func (cfg ContainerConfig) WantsNAT() bool {
if cfg.WantsLANIPv4() {
return false
}
return cfg.AssignNAT == nil || *cfg.AssignNAT
}
func (cfg ContainerConfig) WantsLANDHCP() bool {
return strings.EqualFold(strings.TrimSpace(cfg.LANIPv4Mode), config.LANIPv4ModeDHCP)
}
func (cfg ContainerConfig) WantsLANStaticIPv4() bool {
return strings.EqualFold(strings.TrimSpace(cfg.LANIPv4Mode), config.LANIPv4ModeStatic)
}
func (cfg ContainerConfig) WantsLANIPv4() bool {
return cfg.WantsLANDHCP() || cfg.WantsLANStaticIPv4()
}
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
cfg.NormalizeResourceAliases()
cfg.ReportProgress("preparing", "检查模板与创建参数")
tmpl := FindTemplate(cfg.TemplateID)
if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID)
@@ -306,6 +348,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
if !cfg.ImageLimitConfigured && len(cfg.AllowedImageIDs) == 0 && cfg.TemplateID != "" {
cfg.AllowedImageIDs = []string{cfg.TemplateID}
cfg.ImageLimitConfigured = true
}
if !config.IsValidContainerName(cfg.Name) {
return fmt.Errorf("invalid container name: %s", cfg.Name)
@@ -334,6 +380,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Creating LXC container: %s (ID=%d, template: %s/%s/%s)\n",
lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch)
cfg.ReportProgress("rootfs", "下载模板并创建基础文件系统")
args := []string{"-n", lxcName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
@@ -345,10 +392,27 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
}
cfg.ReportProgress("storage", "复制容器数据到存储磁盘")
storagePoolID, storagePath, err := m.moveContainerToStoragePool(lxcName, cfg.StoragePoolID)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
cfg.ReportProgress("disk", "创建容量限制磁盘并复制 rootfs")
if err := m.applyDiskLimit(lxcName, cfg.DiskGB); err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
cfg.ReportProgress("resources", "配置 CPU、内存与网络限制")
if cfg.WantsLANIPv4() {
iface, err := m.applyLANIPv4Config(lxcName, cfg)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
cfg.LANInterface = iface
}
// Apply resource limits and mandatory security hardening.
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
@@ -356,6 +420,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return err
}
cfg.ReportProgress("addresses", "分配 IPv4、IPv6 与 NAT 端口")
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
@@ -424,51 +489,67 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
trafficResetDate := now[:7] // YYYY-MM for monthly tracking
container := config.Container{
ID: id,
UUID: config.NewContainerUUID(),
Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB,
DiskGB: cfg.DiskGB,
NetworkBWMbps: cfg.NetworkBWMbps,
NetworkDownMbps: cfg.NetworkDownMbps,
NetworkUpMbps: cfg.NetworkUpMbps,
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
TrafficMode: trafficMode,
TrafficInGB: cfg.TrafficInGB,
TrafficOutGB: cfg.TrafficOutGB,
TrafficResetDate: trafficResetDate,
IOSpeedMBps: cfg.IOSpeedMBps,
IOReadMBps: cfg.IOReadMBps,
IOWriteMBps: cfg.IOWriteMBps,
Status: "stopped",
IP: "",
PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments,
VNCPort: 0,
SSHPort: sshPort,
SSHPassword: sshPassword,
PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
ID: id,
UUID: config.NewContainerUUID(),
Name: cfg.Name,
Virtualization: config.VirtualizationLXC,
LXCName: lxcName,
StoragePoolID: storagePoolID,
StoragePath: storagePath,
Template: cfg.TemplateID,
VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB,
DiskGB: cfg.DiskGB,
NetworkBWMbps: cfg.NetworkBWMbps,
NetworkDownMbps: cfg.NetworkDownMbps,
NetworkUpMbps: cfg.NetworkUpMbps,
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
TrafficMode: trafficMode,
TrafficInGB: cfg.TrafficInGB,
TrafficOutGB: cfg.TrafficOutGB,
TrafficResetDate: trafficResetDate,
IOSpeedMBps: cfg.IOSpeedMBps,
IOReadMBps: cfg.IOReadMBps,
IOWriteMBps: cfg.IOWriteMBps,
Status: "stopped",
IP: "",
LANIPv4Mode: normalizedLANIPv4Mode(cfg.LANIPv4Mode),
LANInterface: strings.TrimSpace(cfg.LANInterface),
LANIPv4Address: strings.TrimSpace(cfg.LANIPv4Address),
LANIPv4PrefixLen: cfg.LANIPv4PrefixLen,
LANIPv4Gateway: strings.TrimSpace(cfg.LANIPv4Gateway),
MACAddress: readLXCConfigValue(lxcName, "lxc.net.0.hwaddr"),
PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments,
VNCPort: 0,
SSHPort: sshPort,
SSHPassword: sshPassword,
PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount,
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
ImageLimitConfigured: cfg.ImageLimitConfigured,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
}
container.NormalizeNetworkAssignments()
cfg.ReportProgress("metadata", "保存容器配置")
config.AddContainer(container)
// Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
cfg.ReportProgress("network", "写入容器网络配置")
m.preconfigureNetwork(rootfsPath, cfg)
if len(ipv6Assignments) > 0 {
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
cfg.ReportProgress("ssh", "检测并预配置 SSH 服务")
if configured, err := m.preconfigureSSHIfInstalled(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
} else if !configured {
fmt.Printf("SSH server is not bundled in %s; installation deferred until after first boot\n", lxcName)
}
if sshAccess.PublicKey != "" {
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
@@ -478,6 +559,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
}
}
cfg.ReportProgress("permissions", "转换非特权容器文件权限")
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
_ = m.cleanupContainerStorage(lxcName)
config.RemoveContainer(id)
@@ -486,6 +568,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Set root password AFTER shiftRootfsForUnprivileged,
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
cfg.ReportProgress("credentials", "设置容器登录凭据")
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
}
@@ -494,7 +577,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return nil
}
func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
func (m *Manager) preconfigureNetwork(rootfsPath string, cfg ContainerConfig) {
templateID := cfg.TemplateID
osRelease := ""
if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil {
osRelease = strings.ToLower(string(data))
@@ -510,7 +594,13 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
if isAlpine {
interfaces := filepath.Join(rootfsPath, "etc", "network", "interfaces")
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
content := "auto lo\niface lo inet loopback\n\nauto eth0\n"
if cfg.WantsLANStaticIPv4() {
content += fmt.Sprintf("iface eth0 inet static\n address %s\n netmask %s\n gateway %s\n",
cfg.LANIPv4Address, subnetMaskFromPrefixLen(cfg.LANIPv4PrefixLen), cfg.LANIPv4Gateway)
} else {
content += "iface eth0 inet dhcp\n"
}
_ = os.MkdirAll(filepath.Dir(interfaces), 0755)
_ = os.WriteFile(interfaces, []byte(content), 0644)
_ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot")
@@ -527,8 +617,16 @@ interface-name=eth0
autoconnect=true
[ipv4]
method=auto
`
if cfg.WantsLANStaticIPv4() {
keyfile += fmt.Sprintf(`method=manual
address1=%s/%d,%s
`, cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway)
} else {
keyfile += `method=auto
`
}
keyfile += `
[ipv6]
method=ignore
`
@@ -544,9 +642,14 @@ method=ignore
Name=eth0
[Network]
DHCP=ipv4
`
if cfg.WantsLANStaticIPv4() {
network += fmt.Sprintf("Address=%s/%d\nGateway=%s\nIPv6AcceptRA=no\n", cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway)
} else {
network += `DHCP=ipv4
IPv6AcceptRA=no
`
}
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
}
if !isRHELFamily {
@@ -554,6 +657,226 @@ IPv6AcceptRA=no
}
}
func normalizedLANIPv4Mode(mode string) string {
if strings.EqualFold(strings.TrimSpace(mode), config.LANIPv4ModeDHCP) {
return config.LANIPv4ModeDHCP
}
if strings.EqualFold(strings.TrimSpace(mode), config.LANIPv4ModeStatic) {
return config.LANIPv4ModeStatic
}
return ""
}
func (m *Manager) applyLANIPv4Config(lxcName string, cfg ContainerConfig) (string, error) {
if !cfg.WantsLANIPv4() {
return "", nil
}
if cfg.WantsLANStaticIPv4() {
if err := validateLANStaticIPv4(cfg); err != nil {
return "", err
}
}
iface := cfg.LANInterface
iface = strings.TrimSpace(iface)
if iface == "" || isInvalidLANUplinkInterface(iface) {
iface = defaultLANInterface()
}
if iface == "" {
return "", fmt.Errorf("LAN IPv4 requires an uplink interface")
}
if isInvalidLANUplinkInterface(iface) {
return "", fmt.Errorf("invalid LAN IPv4 uplink interface: %s", iface)
}
if out, err := exec.Command("ip", "link", "show", "dev", iface).CombinedOutput(); err != nil {
return "", fmt.Errorf("LAN IPv4 uplink interface %s not found: %v, output: %s", iface, err, string(out))
}
configPath := filepath.Join(m.LxcPath, lxcName, "config")
data, err := os.ReadFile(configPath)
if err != nil {
return "", fmt.Errorf("failed to read LXC config for LAN DHCP: %v", err)
}
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
values := map[string]string{
"lxc.net.0.type": "macvlan",
"lxc.net.0.link": iface,
"lxc.net.0.flags": "up",
"lxc.net.0.macvlan.mode": "bridge",
}
if cfg.WantsLANStaticIPv4() {
values["lxc.net.0.ipv4.address"] = fmt.Sprintf("%s/%d", strings.TrimSpace(cfg.LANIPv4Address), cfg.LANIPv4PrefixLen)
values["lxc.net.0.ipv4.gateway"] = strings.TrimSpace(cfg.LANIPv4Gateway)
}
seen := map[string]bool{}
next := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !cfg.WantsLANStaticIPv4() && (strings.HasPrefix(trimmed, "lxc.net.0.ipv4.address") || strings.HasPrefix(trimmed, "lxc.net.0.ipv4.gateway")) {
continue
}
replaced := false
for key, value := range values {
if strings.HasPrefix(trimmed, key+" ") || strings.HasPrefix(trimmed, key+"=") {
next = append(next, fmt.Sprintf("%s = %s", key, value))
seen[key] = true
replaced = true
break
}
}
if !replaced {
next = append(next, line)
}
}
for _, key := range []string{"lxc.net.0.type", "lxc.net.0.link", "lxc.net.0.flags", "lxc.net.0.macvlan.mode", "lxc.net.0.ipv4.address", "lxc.net.0.ipv4.gateway"} {
if !seen[key] {
if value, ok := values[key]; ok {
next = append(next, fmt.Sprintf("%s = %s", key, value))
}
}
}
if err := os.WriteFile(configPath, []byte(strings.Join(next, "\n")), 0644); err != nil {
return "", fmt.Errorf("failed to write LXC LAN IPv4 config: %v", err)
}
return iface, nil
}
func validateLANStaticIPv4(cfg ContainerConfig) error {
addr, err := netip.ParseAddr(strings.TrimSpace(cfg.LANIPv4Address))
if err != nil || !addr.Is4() {
return fmt.Errorf("LAN static IPv4 address is invalid")
}
gateway, err := netip.ParseAddr(strings.TrimSpace(cfg.LANIPv4Gateway))
if err != nil || !gateway.Is4() {
return fmt.Errorf("LAN static IPv4 gateway is invalid")
}
if cfg.LANIPv4PrefixLen < 1 || cfg.LANIPv4PrefixLen > 32 {
return fmt.Errorf("LAN static IPv4 prefix length must be 1-32")
}
prefix := netip.PrefixFrom(addr, cfg.LANIPv4PrefixLen).Masked()
if !prefix.Contains(gateway) && cfg.LANIPv4PrefixLen < 32 {
return fmt.Errorf("LAN static IPv4 gateway must be in the same subnet")
}
return nil
}
func subnetMaskFromPrefixLen(prefixLen int) string {
if prefixLen < 0 || prefixLen > 32 {
return "255.255.255.0"
}
mask := uint32(0)
if prefixLen > 0 {
mask = ^uint32(0) << (32 - prefixLen)
}
return fmt.Sprintf("%d.%d.%d.%d", byte(mask>>24), byte(mask>>16), byte(mask>>8), byte(mask))
}
func defaultLANInterface() string {
out, err := exec.Command("ip", "-4", "route", "show", "default").Output()
if err != nil {
return ""
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
for i := 0; i+1 < len(fields); i++ {
if fields[i] == "dev" && !isInvalidLANUplinkInterface(fields[i+1]) {
return fields[i+1]
}
}
}
return ""
}
func isInvalidLANUplinkInterface(name string) bool {
name = strings.TrimSpace(name)
return name == "" ||
name == "lo" ||
strings.HasPrefix(name, "lxc") ||
strings.HasPrefix(name, "docker") ||
strings.HasPrefix(name, "br-") ||
strings.HasPrefix(name, "veth") ||
strings.HasPrefix(name, "virbr") ||
strings.HasPrefix(name, "clmv-")
}
func readLXCConfigValue(lxcName string, key string) string {
data, err := os.ReadFile(filepath.Join("/var/lib/lxc", lxcName, "config"))
if err != nil {
return ""
}
prefix := key + " "
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, prefix) || strings.HasPrefix(trimmed, key+"=") {
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return ""
}
func (m *Manager) ensureLANHostAccess(c *config.Container) error {
if c == nil || !c.UsesLANIPv4() || strings.TrimSpace(c.IP) == "" {
return nil
}
uplink := strings.TrimSpace(c.LANInterface)
if uplink == "" {
uplink = defaultLANInterface()
}
if uplink == "" {
return fmt.Errorf("missing LAN IPv4 uplink interface")
}
shim := lanHostShimName(uplink)
if _, err := exec.Command("ip", "link", "show", "dev", shim).Output(); err != nil {
if out, addErr := exec.Command("ip", "link", "add", shim, "link", uplink, "type", "macvlan", "mode", "bridge").CombinedOutput(); addErr != nil {
return fmt.Errorf("failed to create host macvlan shim %s on %s: %v, output: %s", shim, uplink, addErr, string(out))
}
}
runQuiet("ip", "link", "set", shim, "up")
if out, err := exec.Command("ip", "route", "replace", c.IP+"/32", "dev", shim).CombinedOutput(); err != nil {
return fmt.Errorf("failed to route %s through %s: %v, output: %s", c.IP, shim, err, string(out))
}
return nil
}
func (m *Manager) removeLANHostRoute(c *config.Container) {
if c == nil || !c.UsesLANIPv4() || strings.TrimSpace(c.IP) == "" {
return
}
uplink := strings.TrimSpace(c.LANInterface)
if uplink == "" {
uplink = defaultLANInterface()
}
if uplink == "" {
return
}
runQuiet("ip", "route", "del", c.IP+"/32", "dev", lanHostShimName(uplink))
}
func lanHostShimName(uplink string) string {
cleaned := make([]rune, 0, len(uplink))
for _, r := range uplink {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
cleaned = append(cleaned, r)
}
}
base := strings.ToLower(string(cleaned))
if base == "" {
base = "if"
}
if len(base) <= 10 {
return "clmv-" + base
}
h := fnv.New32a()
_, _ = h.Write([]byte(uplink))
suffix := fmt.Sprintf("%04x", h.Sum32()&0xffff)
if len(base) > 6 {
base = base[:6]
}
return "clmv-" + base + suffix
}
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error {
_ = templateID
@@ -577,6 +900,25 @@ func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode str
return nil
}
// preconfigureSSHIfInstalled keeps image creation independent from external
// package mirrors. Minimal images install SSH asynchronously after first boot.
func (m *Manager) preconfigureSSHIfInstalled(rootfsPath, templateID, sshAuthMode string) (bool, error) {
if !rootfsHasSSHD(rootfsPath) {
return false, nil
}
return true, m.preconfigureSSH(rootfsPath, templateID, sshAuthMode)
}
func rootfsHasSSHD(rootfsPath string) bool {
for _, relativePath := range []string{"usr/sbin/sshd", "sbin/sshd", "usr/bin/sshd"} {
info, err := os.Stat(filepath.Join(rootfsPath, relativePath))
if err == nil && !info.IsDir() {
return true
}
}
return false
}
// applyResourceLimits applies cgroup v2 limits and mandatory security hardening to container config.
func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error {
cfg.NormalizeResourceAliases()
@@ -836,6 +1178,78 @@ func (m *Manager) applyLoopbackDiskLimit(lxcName string, diskGB int) error {
return nil
}
func (m *Manager) moveContainerToStoragePool(lxcName string, requestedPoolID string) (string, string, error) {
sourceDir := filepath.Join(m.LxcPath, lxcName)
requiredBytes := dirSizeBytes(sourceDir)
pool, err := config.SelectStoragePoolForContent(config.StorageContentLXC, requestedPoolID, requiredBytes)
if err != nil {
return "", "", err
}
targetRoot := filepath.Join(pool.Path, "lxc")
targetDir := filepath.Join(targetRoot, lxcName)
sourceAbs, err := filepath.Abs(sourceDir)
if err != nil {
return "", "", err
}
targetAbs, err := filepath.Abs(targetDir)
if err != nil {
return "", "", err
}
if sourceAbs == targetAbs {
return pool.ID, targetAbs, nil
}
if err := os.MkdirAll(targetRoot, 0755); err != nil {
return "", "", err
}
if _, err := os.Lstat(targetDir); err == nil {
return "", "", fmt.Errorf("target storage directory already exists: %s", targetDir)
}
if err := moveLXCStorageDirectory(sourceDir, targetDir); err != nil {
return "", "", err
}
return pool.ID, targetAbs, nil
}
func moveLXCStorageDirectory(sourceDir, targetDir string) error {
if err := os.Rename(sourceDir, targetDir); err == nil {
if err := os.Symlink(targetDir, sourceDir); err != nil {
_ = os.Rename(targetDir, sourceDir)
return fmt.Errorf("failed to create LXC storage symlink: %v", err)
}
return nil
} else if !errors.Is(err, syscall.EXDEV) {
return fmt.Errorf("failed to move LXC container to storage pool: %v", err)
}
if err := copyTree(sourceDir, targetDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to copy LXC container to storage pool: %v", err)
}
backupDir := sourceDir + fmt.Sprintf(".storage-move-%d", time.Now().UnixNano())
if err := os.Rename(sourceDir, backupDir); err != nil {
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to finalize LXC storage move: %v", err)
}
if err := os.Symlink(targetDir, sourceDir); err != nil {
_ = os.Rename(backupDir, sourceDir)
_ = os.RemoveAll(targetDir)
return fmt.Errorf("failed to create LXC storage symlink: %v", err)
}
if err := os.RemoveAll(backupDir); err != nil {
fmt.Printf("Warning: LXC storage moved but source cleanup failed: %v\n", err)
}
return nil
}
func storagePoolAllowsContent(pool config.StoragePool, content string) bool {
for _, item := range pool.ContentTypes {
if item == content {
return true
}
}
return false
}
func (m *Manager) ensureDiskImageMounted(lxcName string) error {
containerDir := filepath.Join(m.LxcPath, lxcName)
rootfsPath := filepath.Join(containerDir, "rootfs")
@@ -891,15 +1305,30 @@ func diskImageMounted(lxcName, rootfsPath string) bool {
if err != nil {
return false
}
targetAbs, err := filepath.Abs(strings.TrimSpace(target))
if err != nil {
return false
return sameFilesystemPath(strings.TrimSpace(target), rootfsPath)
}
func sameFilesystemPath(left, right string) bool {
leftInfo, leftErr := os.Stat(left)
rightInfo, rightErr := os.Stat(right)
if leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) {
return true
}
rootfsAbs, err := filepath.Abs(rootfsPath)
if err != nil {
return false
canonical := func(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err == nil {
absolute = resolved
}
return filepath.Clean(absolute), nil
}
return targetAbs == rootfsAbs
leftPath, leftErr := canonical(left)
rightPath, rightErr := canonical(right)
return leftErr == nil && rightErr == nil && leftPath == rightPath
}
func applyXFSProjectQuota(rootfsPath, lxcName string, diskGB int) error {
@@ -1438,7 +1867,7 @@ func (m *Manager) StartContainer(id int) error {
return err
}
config.UpdateContainerStatus(id, "running")
config.UpdateContainerStatusAndRestore(id, "running", true)
var ip string
for retry := 0; retry < 10; retry++ {
@@ -1452,6 +1881,7 @@ func (m *Manager) StartContainer(id int) error {
c = config.FindContainer(id)
if c != nil {
c.IP = ip
m.refreshContainerIPv4Details(c)
config.SaveConfig()
}
}
@@ -1465,9 +1895,10 @@ func (m *Manager) StartContainer(id int) error {
}
if ip != "" {
if err := m.EnsureSSH(id); err != nil {
return err
if err := m.ensureLANHostAccess(c); err != nil {
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", lxcName, err)
}
m.WarmSSHAsync(id, "container start")
}
if current := config.FindContainer(id); current != nil {
@@ -1488,7 +1919,6 @@ func (m *Manager) StartContainer(id int) error {
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
}
}
fmt.Printf("Container %d (%s) started, IP: %s\n", id, c.Name, ip)
return nil
}
@@ -1656,7 +2086,7 @@ func (m *Manager) StopContainer(id int) error {
status, _ := m.GetContainerStatus(lxcName)
if status != "running" {
config.UpdateContainerStatus(id, "stopped")
config.UpdateContainerStatusAndRestore(id, "stopped", false)
m.CleanPortMappings(id)
CleanFirewallRules(id)
m.cleanupBandwidthLimit(lxcName)
@@ -1671,13 +2101,13 @@ func (m *Manager) StopContainer(id int) error {
output, err := cmd.CombinedOutput()
if err != nil {
if strings.Contains(string(output), "not running") {
config.UpdateContainerStatus(id, "stopped")
config.UpdateContainerStatusAndRestore(id, "stopped", false)
return nil
}
return fmt.Errorf("failed to stop container: %v, output: %s", err, string(output))
}
config.UpdateContainerStatus(id, "stopped")
config.UpdateContainerStatusAndRestore(id, "stopped", false)
fmt.Printf("Container %d (%s) stopped\n", id, c.Name)
return nil
}
@@ -1765,6 +2195,7 @@ ip -4 addr show eth0 2>/dev/null | awk '/inet / {sub(/\/.*/, "", $2); print $2;
return "", fmt.Errorf("no IPv4 address after DHCP repair in %s", lxcName)
}
c.IP = ip
m.refreshContainerIPv4Details(c)
config.SaveConfig()
return ip, nil
}
@@ -1786,6 +2217,10 @@ func (m *Manager) WarmSSH(id int) error {
if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" {
if current := config.FindContainer(id); current != nil {
current.IP = ip
m.refreshContainerIPv4Details(current)
if err := m.ensureLANHostAccess(current); err != nil {
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", lxcName, err)
}
config.SaveConfig()
}
break
@@ -1795,6 +2230,10 @@ func (m *Manager) WarmSSH(id int) error {
if current := config.FindContainer(id); current != nil && current.IP == "" {
if ip, err := m.EnsureContainerIPv4(id); err == nil && ip != "" {
current.IP = ip
m.refreshContainerIPv4Details(current)
if err := m.ensureLANHostAccess(current); err != nil {
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", lxcName, err)
}
config.SaveConfig()
}
}
@@ -2441,6 +2880,17 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
return nil
}
var linkedTarget string
if info, err := os.Lstat(cleanPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(cleanPath); err == nil {
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(cleanPath), target)
}
if abs, err := filepath.Abs(target); err == nil && lxcStorageTargetAllowed(abs) {
linkedTarget = abs
}
}
}
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
m.detachContainerMounts(cleanPath)
@@ -2452,9 +2902,25 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
if err := os.RemoveAll(cleanPath); err != nil {
return fmt.Errorf("failed to remove container directory %s: %v", cleanPath, err)
}
if linkedTarget != "" {
m.detachContainerMounts(linkedTarget)
m.detachContainerLoopDevices(linkedTarget)
_ = os.RemoveAll(linkedTarget)
}
return nil
}
func lxcStorageTargetAllowed(path string) bool {
for _, pool := range config.StoragePoolsForContent(config.StorageContentLXC) {
root := filepath.Join(pool.Path, "lxc")
rel, err := filepath.Rel(root, path)
if err == nil && rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
return true
}
}
return false
}
func (m *Manager) detachContainerMounts(containerDir string) {
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output()
if err != nil {
@@ -2542,6 +3008,71 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
return "", fmt.Errorf("no IPv4 address found for %s (IPv6 is disabled for containers)", lxcName)
}
func (m *Manager) GetContainerIPv4Details(lxcName string) (string, int, string, error) {
script := `
addr="$(ip -4 -o addr show dev eth0 scope global 2>/dev/null | awk '{print $4; exit}')"
gateway="$(ip route show default 0.0.0.0/0 dev eth0 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="via") {print $(i+1); exit}}')"
printf '%s\n%s\n' "$addr" "$gateway"
`
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return "", 0, "", fmt.Errorf("timed out reading IPv4 details for %s", lxcName)
}
if err != nil {
return "", 0, "", fmt.Errorf("failed to read IPv4 details for %s: %v, output: %s", lxcName, err, string(output))
}
lines := strings.Split(strings.TrimRight(string(output), "\n"), "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" {
return "", 0, "", fmt.Errorf("no IPv4 address details found for %s", lxcName)
}
prefix, err := netip.ParsePrefix(strings.TrimSpace(lines[0]))
if err != nil || !prefix.Addr().Is4() {
return "", 0, "", fmt.Errorf("invalid IPv4 address details for %s: %s", lxcName, strings.TrimSpace(lines[0]))
}
gateway := ""
if len(lines) > 1 {
candidate := strings.TrimSpace(lines[1])
if addr, err := netip.ParseAddr(candidate); err == nil && addr.Is4() {
gateway = candidate
}
}
return prefix.Addr().String(), prefix.Bits(), gateway, nil
}
func (m *Manager) refreshContainerIPv4Details(c *config.Container) {
if c == nil || c.IsKVM() {
return
}
ip, prefixLen, gateway, err := m.GetContainerIPv4Details(c.LxcName())
if err != nil {
return
}
changed := false
if ip != "" && c.IP != ip {
c.IP = ip
changed = true
}
if c.UsesLANDHCP() {
if prefixLen > 0 && c.LANIPv4PrefixLen != prefixLen {
c.LANIPv4PrefixLen = prefixLen
changed = true
}
if gateway != "" && c.LANIPv4Gateway != gateway {
c.LANIPv4Gateway = gateway
changed = true
}
}
if c.NormalizeNetworkAssignments() {
changed = true
}
if changed {
config.SaveConfig()
}
}
// ListContainers lists all LXC containers and updates statuses
func (m *Manager) ListContainers() ([]config.Container, error) {
containers := config.AppConfig.Containers
@@ -2557,6 +3088,7 @@ func (m *Manager) ListContainers() ([]config.Container, error) {
ip, err := m.GetContainerIP(containers[i].LxcName())
if err == nil {
containers[i].IP = ip
m.refreshContainerIPv4Details(&containers[i])
}
}
}
@@ -2816,15 +3348,17 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
// Set root password and pre-configure network/SSH via chroot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, templateID)
m.preconfigureNetwork(rootfsPath, ContainerConfig{TemplateID: templateID})
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
}
}
c.SSHPassword = sshAccess.Password
if err := m.preconfigureSSH(rootfsPath, templateID, sshAccess.Mode); err != nil {
if configured, err := m.preconfigureSSHIfInstalled(rootfsPath, templateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
} else if !configured {
fmt.Printf("SSH server is not bundled in %s; installation deferred until after reinstall boot\n", lxcName)
}
if sshAccess.PublicKey != "" {
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
+40
View File
@@ -121,6 +121,46 @@ func TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) {
}
}
func TestRootfsHasSSHD(t *testing.T) {
rootfs := t.TempDir()
if rootfsHasSSHD(rootfs) {
t.Fatal("empty rootfs unexpectedly reports sshd")
}
sshd := filepath.Join(rootfs, "usr", "sbin", "sshd")
if err := os.MkdirAll(filepath.Dir(sshd), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sshd, []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
if !rootfsHasSSHD(rootfs) {
t.Fatal("executable sshd was not detected")
}
}
func TestSameFilesystemPathResolvesContainerStorageSymlink(t *testing.T) {
base := t.TempDir()
storageContainer := filepath.Join(base, "storage", "ct-1")
rootfs := filepath.Join(storageContainer, "rootfs")
if err := os.MkdirAll(rootfs, 0755); err != nil {
t.Fatal(err)
}
lxcPath := filepath.Join(base, "lxc")
if err := os.MkdirAll(lxcPath, 0755); err != nil {
t.Fatal(err)
}
containerLink := filepath.Join(lxcPath, "ct-1")
if err := os.Symlink(storageContainer, containerLink); err != nil {
t.Skipf("directory symlinks are unavailable: %v", err)
}
linkedRootfs := filepath.Join(containerLink, "rootfs")
if !sameFilesystemPath(rootfs, linkedRootfs) {
t.Fatalf("sameFilesystemPath(%q, %q) = false, want true", rootfs, linkedRootfs)
}
}
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
+86
View File
@@ -252,6 +252,7 @@ func EnsureForwardRules(bridge string) {
if bridge == "" {
bridge = "lxcbr0"
}
ensureLibvirtForwardRules(bridge)
rules := [][]string{
{"-i", bridge, "-j", "ACCEPT"},
{"-o", bridge, "-j", "ACCEPT"},
@@ -269,6 +270,33 @@ func EnsureForwardRules(bridge string) {
}
}
func ensureLibvirtForwardRules(bridge string) {
if bridge != "virbr0" || exec.Command("iptables", "-L", "LIBVIRT_FWI", "-n").Run() != nil {
return
}
rules := []struct {
chain string
args []string
}{
{chain: "LIBVIRT_FWI", args: []string{"-o", bridge, "-j", "ACCEPT"}},
{chain: "LIBVIRT_FWO", args: []string{"-i", bridge, "-j", "ACCEPT"}},
{chain: "LIBVIRT_FWX", args: []string{"-i", bridge, "-o", bridge, "-j", "ACCEPT"}},
}
for _, rule := range rules {
if exec.Command("iptables", "-L", rule.chain, "-n").Run() != nil {
continue
}
for {
deleteArgs := append([]string{"-D", rule.chain}, rule.args...)
if exec.Command("iptables", deleteArgs...).Run() != nil {
break
}
}
insertArgs := append([]string{"-I", rule.chain, "1"}, rule.args...)
exec.Command("iptables", insertArgs...).Run()
}
}
// CleanPortMappings removes all iptables rules for a container
func (m *Manager) CleanPortMappings(id int) error {
tag := clicdTag(id)
@@ -371,6 +399,64 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error {
return nil
}
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if c.UsesLANIPv4() {
return nil, fmt.Errorf("public IPv4 cannot be assigned while LAN IPv4 mode is enabled")
}
assignments := []config.PublicIPv4Assignment{}
if auto || len(requested) > 0 {
allocated, err := AllocatePublicIPv4Assignments(id, requested, count, auto)
if err != nil {
return nil, err
}
assignments = allocated
}
c.PublicIPv4s = assignments
reconcilePortMappingHostIPs(c)
c.NormalizeNetworkAssignments()
config.SaveConfig()
_ = m.CleanPortMappings(id)
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
if c.Status == "running" && c.IP != "" {
if err := m.ApplyPortMappings(id); err != nil {
return nil, err
}
}
return c, nil
}
func reconcilePortMappingHostIPs(c *config.Container) {
if c == nil {
return
}
assigned := map[string]bool{}
for _, item := range c.PublicIPv4s {
if addr := strings.TrimSpace(item.Address); addr != "" {
assigned[addr] = true
}
}
replacement := ""
if len(assigned) == 1 {
for addr := range assigned {
replacement = addr
}
}
for i := range c.PortMappings {
hostIP := strings.TrimSpace(c.PortMappings[i].HostIP)
if hostIP == "" || assigned[hostIP] {
continue
}
c.PortMappings[i].HostIP = replacement
}
}
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
return pm, fmt.Errorf("container port must be 1-65535")
+44 -6
View File
@@ -16,7 +16,7 @@ import (
var snapshotMu sync.Mutex
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
snapshotMu.Lock()
defer snapshotMu.Unlock()
@@ -42,12 +42,21 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
if _, err := os.Stat(containerDir); err != nil {
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
}
pool, err := config.SelectStoragePoolForContent(
config.StorageContentSnapshots,
firstString(storagePoolID),
dirSizeBytes(containerDir),
)
if err != nil {
return config.Snapshot{}, err
}
now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
// Use container ID instead of lxcName to avoid collision when containers are recreated
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
baseDir := filepath.Join(pool.Path, "snapshots")
snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, baseDir); err != nil {
return config.Snapshot{}, err
}
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
@@ -100,7 +109,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
if snapshot.Path != "" {
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if err := os.RemoveAll(snapshot.Path); err != nil {
@@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
if snapshot.Path == "" {
return fmt.Errorf("snapshot path is empty")
}
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
if err := safeSnapshotPath(snapshot.Path); err != nil {
return err
}
if _, err := os.Stat(snapshot.Path); err != nil {
@@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD
}
func snapshotBaseDir() string {
return filepath.Join(config.AppConfig.DataDir, "snapshots")
return snapshotBaseDirForPool("")
}
func snapshotBaseDirForPool(poolID string) string {
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
return filepath.Join(pool.Path, "snapshots")
}
return ""
}
func safeSnapshotPath(path string) error {
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
return nil
}
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
return nil
}
}
return fmt.Errorf("unsafe snapshot path: %s", path)
}
func firstString(values []string) string {
if len(values) == 0 {
return ""
}
return strings.TrimSpace(values[0])
}
func copyTree(src string, dst string) error {
@@ -313,6 +348,9 @@ func copyTree(src string, dst string) error {
}
func dirSizeBytes(path string) int64 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
out, err := exec.Command("du", "-s", "-B1", path).Output()
if err != nil {
return 0
+5
View File
@@ -27,6 +27,11 @@ func GetTemplates() []Template {
Distro: "ubuntu", Release: "jammy", Arch: arch,
Description: "Ubuntu 22.04 LTS",
},
{
ID: "debian-trixie", Name: "Debian 13",
Distro: "debian", Release: "trixie", Arch: arch,
Description: "Debian 13 (Trixie)",
},
{
ID: "debian-bookworm", Name: "Debian 12",
Distro: "debian", Release: "bookworm", Arch: arch,
+8
View File
@@ -61,13 +61,16 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/host-history", corsMiddleware(api.AdminMiddleware(api.HandleHostHistory)))
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
mux.HandleFunc("/api/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
@@ -104,13 +107,16 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/v1/host-history", corsMiddleware(api.AuthMiddleware(api.HandleHostHistory)))
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/v1/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
mux.HandleFunc("/api/v1/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
@@ -174,6 +180,8 @@ func setupRoutes(mux *http.ServeMux) {
func Run() error {
// Use embedded frontend files
webFS = GetEmbeddedFS()
api.StartHostMetricSampler()
api.StartContainerMetricSampler()
mux := http.NewServeMux()
setupRoutes(mux)
+1
View File
@@ -0,0 +1 @@

+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.23"
Version = "1.1.26"
Repo = "MengMengCode/CLICD"
)
+20
View File
@@ -4,7 +4,10 @@ import (
"fmt"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"clicd/internal/api"
"clicd/internal/cli"
@@ -16,6 +19,8 @@ import (
"golang.org/x/term"
)
var shutdownCaptureOnce sync.Once
func main() {
isTerminal := term.IsTerminal(int(os.Stdin.Fd()))
@@ -44,7 +49,10 @@ func main() {
_ = cfg
if isServerMode || (!isTerminal && !isCliMode) {
installShutdownStateCapture()
// Restore persisted state
api.ConfigureTaskQueue(cfg.TaskConcurrency)
api.RestoreTasks()
api.RestoreLoginLogs()
@@ -75,6 +83,7 @@ func main() {
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
api.StartHostBootRestore()
lxc.EnsureAllRunningPortMappings()
// Pre-warm SSH for containers already running after host boot or service restart.
@@ -97,6 +106,17 @@ func main() {
}
}
func installShutdownStateCapture() {
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-signals
fmt.Fprintf(os.Stderr, "Received %s, capturing workload restore state...\n", sig)
shutdownCaptureOnce.Do(api.CaptureRuntimeRestoreState)
os.Exit(0)
}()
}
func isWebPanelSystemdRunning() bool {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
+3 -3
View File
@@ -2475,9 +2475,9 @@
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -11,7 +11,7 @@
"vitepress": "^1.6.4"
},
"overrides": {
"vite": "6.4.2",
"vite": "6.4.3",
"esbuild": "0.28.1"
}
}
+6 -6
View File
@@ -1,17 +1,17 @@
{
"name": "clicd-frontend",
"version": "1.1.19",
"version": "1.1.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clicd-frontend",
"version": "1.1.19",
"version": "1.1.25",
"dependencies": {
"@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.7.7",
"axios": "^1.18.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -957,9 +957,9 @@
}
},
"node_modules/axios": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.1.23",
"version": "1.1.26",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,7 +12,7 @@
"@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.7.7",
"axios": "^1.18.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+2
View File
@@ -13,6 +13,7 @@ import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots'
import Routing from './pages/Routing'
import Storage from './pages/Storage'
import SubUserManagement from './pages/SubUserManagement'
import Layout from './components/Layout'
@@ -63,6 +64,7 @@ function App() {
<Route path="security" element={<Security />} />
<Route path="snapshots" element={<Snapshots />} />
<Route path="routing" element={<Routing />} />
<Route path="storage" element={<Storage />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="host-report" element={<HostReport />} />
@@ -1,19 +1,21 @@
import { useEffect } from 'react'
import { useLanguage } from '../contexts/LanguageContext'
import { useDialog } from './Dialog'
export default function BrowserDialogTranslator() {
const { t } = useLanguage()
const { alert: showAlert } = useDialog()
useEffect(() => {
const originalAlert = window.alert
const originalConfirm = window.confirm
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
window.alert = (message?: unknown) => { void showAlert('提示', String(message ?? '')) }
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
return () => {
window.alert = originalAlert
window.confirm = originalConfirm
}
}, [t])
}, [showAlert, t])
return null
}
+420 -65
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
import { useNavigate } from 'react-router-dom'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, StorageInfo, Template } from '../services/api'
import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
@@ -16,6 +17,7 @@ const defaultForm: CreateContainerRequest = {
name: '',
virtualization: 'lxc',
template_id: '',
storage_pool_id: '',
vcpu: 1,
cpu_percent: 100,
ram_mb: 512,
@@ -33,6 +35,11 @@ const defaultForm: CreateContainerRequest = {
extra_ports: [],
port_mapping_count: 2,
assign_nat: true,
lan_ipv4_mode: '',
lan_interface: '',
lan_ipv4_address: '',
lan_ipv4_prefix_len: 24,
lan_ipv4_gateway: '',
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
@@ -43,10 +50,13 @@ const defaultForm: CreateContainerRequest = {
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
allowed_image_ids: [],
image_limit_configured: false,
expires_at: '',
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const navigate = useNavigate()
const dialog = useDialog()
const { language } = useLanguage()
const networkText = createNetworkText[language]
@@ -55,6 +65,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const [batchCount, setBatchCount] = useState(1)
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [hostReport, setHostReport] = useState<HostProbeReport | null>(null)
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(true)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('')
@@ -67,7 +80,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
setTemplates(data)
setForm((prev) => {
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
return applyTemplateDefaults({ ...prev, template_id: templateID })
const allowed = new Set(data.map((item) => item.id))
const selectedAllowedIDs = (prev.allowed_image_ids || []).filter((id) => allowed.has(id))
return applyTemplateDefaults({
...prev,
template_id: templateID,
allowed_image_ids: prev.image_limit_configured ? selectedAllowedIDs : (templateID ? [templateID] : []),
image_limit_configured: true,
})
})
})
.catch(console.error)
@@ -88,8 +108,30 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
getHostInfo()
.then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null))
getHostReport()
.then((res) => setHostReport(res.data.data || null))
.catch(() => setHostReport(null))
}, [isOpen, form.virtualization])
useEffect(() => {
if (!isOpen) return
let active = true
setStorageLoading(true)
getStorageInfo()
.then((res) => {
if (active) setStorageInfo(res.data.data || null)
})
.catch(() => {
if (active) setStorageInfo(null)
})
.finally(() => {
if (active) setStorageLoading(false)
})
return () => { active = false }
}, [isOpen])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefixes = ipv6Status?.prefixes || []
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
@@ -99,6 +141,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
const storagePools = useMemo(() => {
const content = form.virtualization === 'kvm' ? 'kvm' : 'lxc'
return (storageInfo?.pools || []).filter((pool) => pool.enabled && pool.available !== false && (pool.content_types || []).includes(content))
}, [storageInfo, form.virtualization])
const storageReady = storagePools.length > 0
useEffect(() => {
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
@@ -107,8 +154,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
}, [hostInfo, kvmAvailable, form.virtualization])
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const natEnabled = form.assign_nat !== false
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
const lanIPv4Enabled = form.lan_ipv4_mode === 'dhcp' || form.lan_ipv4_mode === 'static'
const lanStaticEnabled = form.lan_ipv4_mode === 'static'
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
const defaultLANInterface = lanInterfaces[0]?.name || ''
const customNATPorts = form.extra_ports || []
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2, customNATPorts.length + 1) : 0
const linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
@@ -117,6 +169,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const count = natPortCount
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
}, [natEnabled, natPortCount])
const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
@@ -160,11 +213,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return
}
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false && form.lan_ipv4_mode !== 'dhcp' && form.lan_ipv4_mode !== 'static') {
dialog.alert('提示', '请勾选任意一个可用网络')
return
}
if (form.lan_ipv4_mode === 'static') {
if (!isIPv4Address(form.lan_ipv4_address || '') || !isIPv4Address(form.lan_ipv4_gateway || '') || !form.lan_ipv4_prefix_len) {
dialog.alert('局域网 IPv4 配置有误', '请填写有效的 IPv4 地址、子网掩码和网关')
return
}
}
if (!storageReady) {
dialog.alert('未配置存储', `请先在存储管理中为 ${form.virtualization === 'kvm' ? 'KVM 磁盘' : 'LXC 容器'}开启至少一块存储磁盘`)
return
}
const authError = validateSSHAuthInputs(form)
if (authError) {
dialog.alert('登录方式有误', authError)
@@ -183,11 +248,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
...boundedForm,
name,
assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0,
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2, (boundedForm.extra_ports || []).length + 1) : 0,
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
extra_ports: [],
extra_ports: wantsNAT ? (boundedForm.extra_ports || []) : [],
})
}
@@ -197,7 +262,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
await onSuccess(containers)
onClose()
setBatchCount(1)
setForm({ ...defaultForm, template_id: templates[0]?.id || '' })
setForm({ ...defaultForm, template_id: templates[0]?.id || '', allowed_image_ids: templates[0]?.id ? [templates[0].id] : [], image_limit_configured: true })
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
@@ -210,7 +275,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-3xl max-h-[92vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-black"></h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭">
@@ -218,8 +283,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="px-5 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="容器名称">
<input
type="text"
@@ -241,7 +306,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))}
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
@@ -252,7 +317,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
onClick={() => {
if (kvmAvailable) {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))
}
}}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
@@ -270,7 +335,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
) : (
<select
value={form.template_id}
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
onChange={(event) => {
const templateID = event.target.value
const allowed = new Set(form.allowed_image_ids || [])
if (templateID) allowed.add(templateID)
setForm(applyTemplateDefaults({ ...form, template_id: templateID, allowed_image_ids: Array.from(allowed), image_limit_configured: true }))
}}
className={inputClass}
>
{templates.map((template) => (
@@ -283,6 +353,71 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
<Field label="存储磁盘">
{storageLoading ? (
<div className="flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600">
<RefreshCw className="h-4 w-4 animate-spin" />
...
</div>
) : storagePools.length > 0 ? (
<select
value={form.storage_pool_id || ''}
onChange={(event) => setForm({ ...form, storage_pool_id: event.target.value })}
className={inputClass}
>
<option value=""></option>
{storagePools.map((pool) => (
<option key={pool.id} value={pool.id}>
{pool.name} · {pool.mount_point || pool.path}
</option>
))}
</select>
) : (
<div className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
<span>{form.virtualization === 'kvm' ? ' KVM 磁盘' : ' LXC 容器'}</span>
<button
type="button"
onClick={() => { onClose(); navigate('/storage') }}
className="shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100"
>
</button>
</div>
)}
</Field>
{templates.length > 0 && (
<Field label="子用户可用镜像">
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
<div className="mb-2 text-xs text-gray-500"></div>
<div className="grid gap-2 sm:grid-cols-2">
{templates.map((template) => {
const checked = (form.allowed_image_ids || []).includes(template.id)
const current = template.id === form.template_id
return (
<label key={template.id} className={`flex cursor-pointer items-start gap-2 rounded border px-2.5 py-2 text-xs ${checked ? 'border-black bg-white' : 'border-gray-200 bg-white hover:bg-gray-50'}`}>
<input
type="checkbox"
checked={checked}
onChange={() => {
const currentIDs = form.allowed_image_ids || []
const next = checked ? currentIDs.filter((id) => id !== template.id) : [...currentIDs, template.id]
setForm({ ...form, allowed_image_ids: next, image_limit_configured: true })
}}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
/>
<span className="min-w-0">
<span className="block truncate font-medium text-gray-800">{template.name}{current ? '(当前系统)' : ''}</span>
<span className="block text-gray-500">{template.arch} · {template.distro} {template.release}</span>
</span>
</label>
)
})}
</div>
</div>
</Field>
)}
{linuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
<div className="mb-2 font-medium text-gray-800"></div>
@@ -332,6 +467,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div>
)}
<div className="grid gap-3 lg:grid-cols-2">
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<label className="flex items-start gap-3">
<input
@@ -342,7 +478,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
...form,
assign_ipv4: event.target.checked,
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [] } : {}),
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [], lan_ipv4_mode: '', lan_interface: '' } : {}),
})}
className="mt-1"
/>
@@ -408,6 +544,98 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
)}
</div>
<div className={`rounded-md border px-3 py-2 text-sm ${form.virtualization === 'lxc' && lanInterfaces.length > 0 ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
<input
type="checkbox"
checked={lanIPv4Enabled}
disabled={form.virtualization !== 'lxc' || lanInterfaces.length === 0}
onChange={(event) => {
const checked = event.target.checked
setForm({
...form,
lan_ipv4_mode: checked ? 'dhcp' : '',
lan_interface: checked ? (form.lan_interface || defaultLANInterface) : '',
assign_nat: checked ? false : form.assign_nat,
port_mapping_count: checked ? 0 : form.port_mapping_count,
extra_ports: checked ? [] : form.extra_ports,
assign_ipv4: checked ? false : form.assign_ipv4,
public_ipv4s: checked ? [] : form.public_ipv4s,
ipv4_count: checked ? 0 : form.ipv4_count,
})
}}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800"> DHCP</span>
<span className="block text-xs text-gray-500">
{lanInterfaces.length > 0 ? 'macvlan 独立局域网 IP' : '未检测到可用上联网卡'}
</span>
</span>
</label>
{lanIPv4Enabled && (
<select
value={form.lan_interface || defaultLANInterface}
onChange={(event) => setForm({ ...form, lan_interface: event.target.value })}
className="h-9 w-32 shrink-0 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-black"
>
{lanInterfaces.map((item) => (
<option key={item.name} value={item.name}>{item.name}</option>
))}
</select>
)}
</div>
{lanIPv4Enabled && (
<div className="mt-3 space-y-3 pl-6">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm({ ...form, lan_ipv4_mode: 'dhcp' })}
className={`rounded-md border px-3 py-2 text-xs font-medium ${form.lan_ipv4_mode === 'dhcp' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
DHCP
</button>
<button
type="button"
onClick={() => setForm({ ...form, lan_ipv4_mode: 'static' })}
className={`rounded-md border px-3 py-2 text-xs font-medium ${lanStaticEnabled ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
</button>
</div>
{lanStaticEnabled && (
<div className="grid gap-3 sm:grid-cols-3">
<Field label="IPv4 地址">
<input
value={form.lan_ipv4_address || ''}
onChange={(event) => setForm({ ...form, lan_ipv4_address: event.target.value })}
className={inputClass}
placeholder="192.168.2.250"
/>
</Field>
<Field label="子网掩码">
<input
value={subnetMaskFromPrefixLen(form.lan_ipv4_prefix_len || 24)}
onChange={(event) => setForm({ ...form, lan_ipv4_prefix_len: prefixLenFromSubnetMask(event.target.value) || 24 })}
className={inputClass}
placeholder="255.255.255.0"
/>
</Field>
<Field label="网关">
<input
value={form.lan_ipv4_gateway || ''}
onChange={(event) => setForm({ ...form, lan_ipv4_gateway: event.target.value })}
className={inputClass}
placeholder="192.168.2.202"
/>
</Field>
</div>
)}
</div>
)}
</div>
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
@@ -436,6 +664,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</span>
)}
</div>
{form.assign_ipv6 && (
<div className="mt-3 space-y-3 pl-6">
<div className="grid grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={(form.ipv6_addresses || []).length === 0}
onChange={() => setForm({ ...form, ipv6_addresses: [] })}
/>
Random assign
</label>
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={(form.ipv6_addresses || []).length > 0}
onChange={() => setForm({ ...form, ipv6_addresses: [''], ipv6_count: 1 })}
/>
Custom assign
</label>
</div>
{(form.ipv6_addresses || []).length > 0 && (
<textarea
value={(form.ipv6_addresses || []).join('\n')}
onChange={(event) => {
const next = splitAddressLines(event.target.value)
setForm({ ...form, ipv6_addresses: next.length ? next : [''], ipv6_count: Math.max(1, next.length || 1) })
}}
className={`${inputClass} min-h-20 font-mono text-xs`}
placeholder="2001:db8:100::100"
/>
)}
</div>
)}
</div>
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
@@ -451,7 +712,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
assign_nat: checked,
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
extra_ports: [],
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0 } : {}),
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0, lan_ipv4_mode: '', lan_interface: '' } : {}),
})
}}
className="mt-1"
@@ -463,25 +724,57 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</span>
</span>
</label>
{natEnabled && (
{natEnabled && customNATPorts.length === 0 && (
<span className="block w-24 shrink-0">
<NumberInput
value={natPortCount}
min={2}
max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true, extra_ports: [] })}
/>
</span>
)}
</div>
{natEnabled && (
<div className="mt-2 pl-6">
<div className="mt-2 space-y-2 pl-6">
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATPorts.length === 0}
onChange={() => setForm({ ...form, extra_ports: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
/>
Auto ports
</label>
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATPorts.length > 0}
onChange={() => {
const next = customNATPorts.length > 0 ? customNATPorts : [22002]
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
}}
/>
Custom ports
</label>
</div>
{customNATPorts.length > 0 && (
<textarea
value={customNATPorts.join('\n')}
onChange={(event) => {
const next = parsePortList(event.target.value)
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
}}
className={`${inputClass} min-h-16 font-mono text-xs`}
placeholder={'22002\n8080\n8443'}
/>
)}
<div className="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">
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {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">
{natPreviewPorts.map((port, index) => (
<span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
</span>
))}
@@ -489,8 +782,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Field label="vCPU">
<NumberInput
value={form.vcpu}
@@ -513,9 +807,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
/>
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
</Field>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Field label="磁盘 (GB)">
<NumberInput
value={form.disk_gb}
@@ -526,26 +817,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
/>
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
</Field>
<div className="grid grid-cols-2 gap-3 md:col-span-2">
<Field label="下行带宽 (Mbps)">
<NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
</Field>
<Field label="上行带宽 (Mbps)">
<NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
</Field>
<Field label="读取 IO (MB/s)">
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
</Field>
<Field label="写入 IO (MB/s)">
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
</Field>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Traffic control */}
<Field label="下行带宽 (Mbps)">
<NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
</Field>
<Field label="上行带宽 (Mbps)">
<NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
</Field>
<Field label="读取 IO (MB/s)">
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
</Field>
<Field label="写入 IO (MB/s)">
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
</Field>
<div>
<div className="flex items-center gap-3 mb-2">
<div className="mb-1.5 flex items-center justify-between gap-2">
<label className="text-sm font-medium text-gray-700"></label>
<select
value={form.traffic_mode}
@@ -559,10 +844,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
{form.traffic_mode === 'total' ? (
<div className="flex items-center gap-2">
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
<span className="text-xs text-gray-400">GB (0=)</span>
<span className="shrink-0 text-xs text-gray-400">GB</span>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-2">
<Field label="入站 (GB)">
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
</Field>
@@ -572,7 +857,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div>
)}
</div>
<Field label="子用户快照上限">
<NumberInput
value={form.snapshot_limit}
@@ -581,21 +865,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
/>
</Field>
<Field label="到期时间">
<div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="date"
value={form.expires_at}
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
min={new Date().toISOString().slice(0, 10)}
className={`${inputClass} pl-10`}
/>
</div>
<p className="mt-1 text-[11px] leading-4 text-gray-400"></p>
</Field>
</div>
<Field label="到期时间">
<div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="date"
value={form.expires_at}
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
min={new Date().toISOString().slice(0, 10)}
className={`${inputClass} pl-10`}
/>
</div>
<p className="text-xs text-gray-400 mt-1.5"></p>
</Field>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
@@ -604,7 +887,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</button>
<button
onClick={handleSubmit}
disabled={loading}
disabled={loading || storageLoading || !storageReady}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '创建中...' : '创建容器'}
@@ -711,10 +994,15 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
const normalized = applyTemplateDefaults(form)
const wantsLANDHCP = normalized.virtualization === 'lxc' && normalized.lan_ipv4_mode === 'dhcp'
const wantsLANStatic = normalized.virtualization === 'lxc' && normalized.lan_ipv4_mode === 'static'
const wantsLANIPv4 = wantsLANDHCP || wantsLANStatic
const wantsIPv4 = !!normalized.assign_ipv4
const wantsIPv6 = !!normalized.assign_ipv6
// IPv4 and NAT are mutually exclusive
const wantsNAT = wantsIPv4 ? false : normalized.assign_nat !== false
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
const extraPorts = wantsNAT ? normalizePortList(normalized.extra_ports || []) : []
const portMappingCount = wantsNAT ? clampInt(Math.max(normalized.port_mapping_count || 2, extraPorts.length + 1), 2, 64, 2) : 0
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
return {
@@ -723,13 +1011,19 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
ram_mb: Math.round(normalized.ram_mb),
disk_gb: Math.round(normalized.disk_gb),
assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
port_mapping_count: portMappingCount,
extra_ports: extraPorts,
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '',
lan_ipv4_prefix_len: wantsLANStatic ? clampInt(normalized.lan_ipv4_prefix_len || 24, 1, 32, 24) : 0,
lan_ipv4_gateway: wantsLANStatic ? (normalized.lan_ipv4_gateway || '').trim() : '',
assign_ipv4: wantsIPv4,
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
assign_ipv6: wantsIPv6,
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []).map((item) => item.trim()).filter(Boolean) : [],
ssh_auth_mode: sshAuthMode,
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
@@ -746,6 +1040,16 @@ function validateSSHAuthInputs(form: CreateContainerRequest) {
return ''
}
function getLANDHCPInterfaces(report: HostProbeReport | null) {
const interfaces = report?.network_interfaces || []
return interfaces.filter((item) => {
const name = item.name || ''
if (!name || name === 'lo') return false
if (name.startsWith('lxc') || name.startsWith('docker') || name.startsWith('br-') || name.startsWith('veth') || name.startsWith('virbr') || name.startsWith('clmv-')) return false
return (item.state || '').toLowerCase() === 'up'
})
}
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
if (!isWindowsTemplate(form.template_id)) return form
return {
@@ -771,6 +1075,57 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
return Math.min(Math.max(next, min), max ?? next)
}
function parsePortList(value: string) {
return normalizePortList(
value
.split(/[\s,;]+/)
.map((item) => Number(item.trim()))
)
}
function normalizePortList(ports: number[]) {
const seen = new Set<number>()
const result: number[] = []
for (const port of ports) {
if (!Number.isFinite(port)) continue
const next = Math.round(port)
if (next < 1 || next > 65535 || seen.has(next)) continue
seen.add(next)
result.push(next)
if (result.length >= 63) break
}
return result
}
function isIPv4Address(value: string) {
const parts = value.trim().split('.')
return parts.length === 4 && parts.every((part) => {
if (!/^\d+$/.test(part)) return false
const n = Number(part)
return n >= 0 && n <= 255
})
}
function splitAddressLines(value: string) {
return value
.split(/[\n,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function subnetMaskFromPrefixLen(prefixLen: number) {
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0'
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
return [24, 16, 8, 0].map((shift) => (mask >>> shift) & 255).join('.')
}
function prefixLenFromSubnetMask(mask: string) {
if (!isIPv4Address(mask)) return 0
const bits = mask.split('.').map((part) => Number(part).toString(2).padStart(8, '0')).join('')
if (!/^1*0*$/.test(bits)) return 0
return bits.indexOf('0') === -1 ? 32 : bits.indexOf('0')
}
const createNetworkText = {
zh: {
publicIPv4: '公网 IPv4',
+87 -41
View File
@@ -1,17 +1,23 @@
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
type DialogType = 'confirm' | 'alert'
interface DialogState {
open: boolean
type: DialogType
title: string
message: string
resolve?: (value: boolean) => void
}
type ToastTone = 'success' | 'error' | 'warning' | 'info'
interface ToastState {
id: number
title: string
message: string
tone: ToastTone
}
interface DialogContextType {
confirm: (title: string, message: string) => Promise<boolean>
alert: (title: string, message: string) => Promise<void>
@@ -19,67 +25,107 @@ interface DialogContextType {
const DialogContext = createContext<DialogContextType | undefined>(undefined)
const toastStyles = {
success: { icon: CheckCircle2, iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-300', borderClass: 'border-emerald-200 dark:border-emerald-800' },
error: { icon: CircleAlert, iconClass: 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300', borderClass: 'border-red-200 dark:border-red-800' },
warning: { icon: AlertTriangle, iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300', borderClass: 'border-amber-200 dark:border-amber-800' },
info: { icon: Info, iconClass: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300', borderClass: 'border-gray-200 dark:border-gray-700' },
}
function toastTone(title: string): ToastTone {
if (/失败|错误|异常|不可用|failed|error/i.test(title)) return 'error'
if (/提示|警告|未配置|格式|配额|封禁|warning/i.test(title)) return 'warning'
if (/完成|成功|已保存|success/i.test(title)) return 'success'
return 'info'
}
export function DialogProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
const [dialog, setDialog] = useState<DialogState>({ open: false, title: '', message: '' })
const [toasts, setToasts] = useState<ToastState[]>([])
const toastID = useRef(0)
const toastTimers = useRef(new Map<number, number>())
const { t } = useLanguage()
const confirm = useCallback((title: string, message: string) => {
return new Promise<boolean>((resolve) => {
setDialog({ open: true, type: 'confirm', title, message, resolve })
setDialog({ open: true, title, message, resolve })
})
}, [])
const dismissToast = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id))
const timer = toastTimers.current.get(id)
if (timer !== undefined) window.clearTimeout(timer)
toastTimers.current.delete(id)
}, [])
const alert = useCallback((title: string, message: string) => {
return new Promise<void>((resolve) => {
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
})
const id = ++toastID.current
setToasts((current) => [...current, { id, title, message, tone: toastTone(title) }].slice(-4))
const timer = window.setTimeout(() => dismissToast(id), 4200)
toastTimers.current.set(id, timer)
return Promise.resolve()
}, [dismissToast])
useEffect(() => () => {
toastTimers.current.forEach((timer) => window.clearTimeout(timer))
toastTimers.current.clear()
}, [])
const close = (result: boolean) => {
dialog.resolve?.(result)
setDialog({ open: false, type: 'alert', title: '', message: '' })
setDialog({ open: false, title: '', message: '' })
}
return (
<DialogContext.Provider value={{ confirm, alert }}>
{children}
{dialog.open && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
}`}>
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</div>
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
{dialog.type === 'alert' && (
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
<X className="w-4 h-4" />
<div className="pointer-events-none fixed right-4 top-4 z-[120] flex w-[calc(100vw-2rem)] max-w-sm flex-col gap-2" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => {
const style = toastStyles[toast.tone]
const ToastIcon = style.icon
return (
<div key={toast.id} className={`pointer-events-auto rounded-lg border bg-white shadow-lg dark:bg-gray-900 dark:shadow-black/40 ${style.borderClass}`} role="status">
<div className="flex items-start gap-3 p-3.5">
<div className={`mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${style.iconClass}`}>
<ToastIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-gray-900 dark:text-white">{t(toast.title)}</div>
<div className="mt-0.5 break-words text-sm leading-5 text-gray-600 dark:text-gray-300">{t(toast.message)}</div>
</div>
<button onClick={() => dismissToast(toast.id)} className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
)
})}
</div>
{dialog.open && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70">
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-700">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300">
<AlertTriangle className="h-4 w-4" />
</div>
<h3 className="flex-1 text-sm font-semibold text-black dark:text-white">{t(dialog.title)}</h3>
</div>
<div className="px-5 py-4">
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t(dialog.message)}</p>
</div>
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
{dialog.type === 'confirm' && (
<button
onClick={() => close(false)}
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
>
{t('取消')}
</button>
)}
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
<button
onClick={() => close(false)}
className="rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
>
{t('取消')}
</button>
<button
onClick={() => close(true)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
dialog.type === 'confirm'
? 'bg-black text-white hover:bg-gray-800'
: 'bg-black text-white hover:bg-gray-800'
}`}
className="rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
>
{dialog.type === 'confirm' ? t('确认') : t('确定')}
{t('确认')}
</button>
</div>
</div>
+14
View File
@@ -6,6 +6,7 @@ import {
Code2,
Cpu,
Camera,
HardDrive,
LayoutDashboard,
LogOut,
Moon,
@@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
const isRoutingPage = location.pathname.startsWith('/routing')
const isStoragePage = location.pathname.startsWith('/storage')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isHostReportPage = location.pathname.startsWith('/host-report')
@@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/storage')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isStoragePage
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<HardDrive className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
+42
View File
@@ -83,6 +83,8 @@ body {
/* Shadow */
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
.dark .shadow-lg,
.dark .shadow-xl { box-shadow: 0 12px 28px rgba(0,0,0,0.45) !important; }
/* bg-black buttons in dark mode -> light */
.dark .bg-black { background-color: #f9fafb !important; }
@@ -121,6 +123,7 @@ body {
.dark .bg-amber-50 { background-color: #451a03 !important; }
.dark .bg-emerald-50 { background-color: #064e3b !important; }
.dark .bg-amber-100 { background-color: #78350f !important; }
.dark .bg-indigo-50 { background-color: #1e1b4b !important; }
/* Status badge text */
.dark .text-green-700 { color: #6ee7b7 !important; }
@@ -129,6 +132,14 @@ body {
.dark .text-amber-600 { color: #fcd34d !important; }
.dark .text-amber-700 { color: #fcd34d !important; }
.dark .text-emerald-700 { color: #6ee7b7 !important; }
.dark .text-emerald-600 { color: #6ee7b7 !important; }
.dark .text-amber-800 { color: #fde68a !important; }
.dark .text-indigo-700 { color: #a5b4fc !important; }
/* Colored notification borders */
.dark .border-emerald-200 { border-color: #065f46 !important; }
.dark .border-red-200 { border-color: #991b1b !important; }
.dark .border-amber-200 { border-color: #92400e !important; }
/* Focus ring */
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
@@ -137,6 +148,37 @@ body {
/* Accent */
.dark .accent-black { accent-color: #f9fafb !important; }
/* Native form controls */
.dark input,
.dark select,
.dark textarea { color-scheme: dark; }
/* Explicit dark variants take precedence over the compatibility overrides above. */
.dark .dark\:bg-white { background-color: #f9fafb !important; }
.dark .dark\:bg-gray-950 { background-color: #030712 !important; }
.dark .dark\:bg-gray-900 { background-color: #111827 !important; }
.dark .dark\:bg-gray-800 { background-color: #1f2937 !important; }
.dark .dark\:bg-gray-700 { background-color: #374151 !important; }
.dark .dark\:bg-emerald-950 { background-color: #022c22 !important; }
.dark .dark\:bg-red-950 { background-color: #450a0a !important; }
.dark .dark\:bg-amber-950 { background-color: #451a03 !important; }
.dark .dark\:text-white { color: #f9fafb !important; }
.dark .dark\:text-black { color: #111827 !important; }
.dark .dark\:text-gray-300 { color: #d1d5db !important; }
.dark .dark\:text-gray-400 { color: #9ca3af !important; }
.dark .dark\:text-gray-500 { color: #6b7280 !important; }
.dark .dark\:text-emerald-300 { color: #6ee7b7 !important; }
.dark .dark\:text-red-300 { color: #fca5a5 !important; }
.dark .dark\:text-amber-300 { color: #fcd34d !important; }
.dark .dark\:border-gray-700 { border-color: #374151 !important; }
.dark .dark\:border-emerald-800 { border-color: #065f46 !important; }
.dark .dark\:border-red-800 { border-color: #991b1b !important; }
.dark .dark\:border-amber-800 { border-color: #92400e !important; }
.dark .dark\:hover\:bg-gray-800:hover { background-color: #1f2937 !important; color: inherit !important; }
.dark .dark\:hover\:bg-gray-700:hover { background-color: #374151 !important; color: inherit !important; }
.dark .dark\:hover\:bg-gray-200:hover { background-color: #e5e7eb !important; color: #111827 !important; }
.dark .dark\:hover\:text-white:hover { color: #f9fafb !important; }
/* Spinner */
.dark .border-black { border-color: #f9fafb !important; }
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
+185 -2
View File
@@ -81,7 +81,7 @@ const scopeGroups = [
['container:delete', '删除容器'],
['container:resize', '资源/到期'],
['container:traffic', '流量管理'],
['container:network', '端口映射'],
['container:network', '网络与端口映射'],
['container:password', '重置密码'],
['ipv6:assign', '分配 IPv6'],
],
@@ -140,6 +140,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
endpoints: [
['GET', '/api/v1/dashboard', '控制面板统计'],
['GET', '/api/v1/host-info', '主机资源'],
['GET', '/api/v1/host-history', '宿主机历史指标(后台每 30 秒采集)'],
['GET', '/api/v1/host-report', '宿主机硬件、网络与运行环境探测报告'],
['GET', '/api/v1/routing', 'NAT/IPv4/IPv6 路由'],
['PUT', '/api/v1/routing', '更新公网 IPv4/IPv6 池'],
['POST', '/api/v1/routing/ipv4-scan', '扫描公网 IPv4 段'],
@@ -161,6 +163,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['POST', '/api/v1/containers/{id}/reinstall', '重装'],
['DELETE', '/api/v1/containers/{id}/delete', '删除'],
['GET', '/api/v1/containers/{id}/usage', '资源用量'],
['GET', '/api/v1/containers/{id}/history', '容器历史指标(后台每 30 秒采集)'],
['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
@@ -168,6 +171,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
['PUT', '/api/v1/containers/{id}/public-ipv4', '更新独立公网 IPv4 地址'],
['PUT', '/api/v1/containers/{id}/ipv6-addresses', '更新独立 IPv6 地址'],
],
},
{
@@ -193,6 +198,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
endpoints: [
['GET', '/api/v1/templates', '模板列表'],
['GET', '/api/v1/images', '镜像管理列表'],
['GET', '/api/v1/images/enabled?type=lxc&container={id}', '可用于创建或重装的已启用镜像'],
['POST', '/api/v1/images/download', '下载镜像'],
['POST', '/api/v1/images/cancel', '取消镜像下载'],
['DELETE', '/api/v1/images/delete', '删除镜像缓存'],
@@ -211,6 +217,21 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
],
},
{
title: '主机与设置',
endpoints: [
['GET', '/api/v1/storage', '已挂载磁盘、存储池和空间占用'],
['PUT', '/api/v1/storage', '更新各磁盘的存储用途和默认盘'],
['GET', '/api/v1/task-queue/settings', '任务队列并发状态'],
['PUT', '/api/v1/task-queue/settings', '调整任务并发数量'],
['GET', '/api/v1/ssl', 'SSL 配置和证书状态'],
['PUT', '/api/v1/ssl', '更新 SSL 配置'],
['GET', '/api/v1/webssh-origins', 'WebSSH/VNC Origin 白名单'],
['PUT', '/api/v1/webssh-origins', '更新 WebSSH/VNC Origin 白名单'],
['GET', '/api/v1/language', '面板语言'],
['PUT', '/api/v1/language', '更新面板语言'],
],
},
{
title: '账号与日志',
endpoints: [
@@ -728,6 +749,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
name: 'demo-lxc-01',
virtualization: 'lxc',
template_id: 'debian-bookworm',
storage_pool_id: 'disk-root',
vcpu: 1,
ram_mb: 512,
disk_gb: 10,
@@ -744,7 +766,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
extra_ports: [8080],
port_mapping_count: 2,
assign_nat: true,
lan_ipv4_mode: '',
lan_interface: '',
lan_ipv4_address: '',
lan_ipv4_prefix_len: 24,
lan_ipv4_gateway: '',
snapshot_limit: 1,
allowed_image_ids: ['debian-bookworm'],
image_limit_configured: true,
assign_ipv4: false,
ipv4_count: 1,
public_ipv4s: [],
@@ -780,6 +809,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
},
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
'PUT /api/v1/containers/{id}/public-ipv4': {
mode: 'random',
count: 1,
},
'PUT /api/v1/containers/{id}/ipv6-addresses': {
mode: 'custom',
addresses: ['2001:db8:100::1005'],
},
'POST /api/v1/containers/{id}/port-mappings': {
container_port: 8080,
host_port: 61320,
@@ -792,6 +829,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
protocol: 'tcp',
description: 'HTTP',
},
'POST /api/v1/containers/{id}/snapshots': { storage_pool_id: 'disk-root' },
'POST /api/v1/containers/{id}/snapshots/schedule': {
enabled: true,
interval_hours: 24,
@@ -802,6 +840,31 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
'PUT /api/v1/storage': {
pools: [
{
id: 'disk-root',
name: 'system (/)',
path: '/var/lib/clicd',
mount_point: '/',
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
enabled: true,
},
],
},
'PUT /api/v1/task-queue/settings': { concurrency: 4 },
'PUT /api/v1/ssl': {
enabled: true,
mode: 'letsencrypt',
target: 'panel.example.com',
email: 'admin@example.com',
apply_now: false,
},
'PUT /api/v1/webssh-origins': {
origins: ['https://panel.example.com'],
},
'PUT /api/v1/language': { language: 'zh' },
'PUT /api/v1/routing': {
items: [
{
@@ -910,6 +973,37 @@ const responseSamples: Record<string, unknown> = {
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
},
},
'GET /api/v1/host-history': {
success: true,
data: [
{
ts: 1784642400000,
cpu: 8.4,
memory: 21.3,
network: 12288,
network_rx: 10240,
network_tx: 2048,
disk_io: 1052672,
disk_read: 4096,
disk_write: 1048576,
disk_usage_pct: 18.8,
},
],
},
'GET /api/v1/host-report': {
success: true,
data: {
generated_at: '2026-07-21 14:00:00',
hostname: 'ubuntu',
os: 'Ubuntu 22.04.5 LTS',
kernel: 'Linux 6.8.0-1054-oracle aarch64 GNU/Linux',
cpu: { model: 'Neoverse-N1', cores: 4, threads: 4, architecture: 'arm64', virtualization: true },
memory: { total_mb: 11980, used_mb: 2100, free_mb: 9880, modules: [] },
runtime: { lxc_available: true, kvm_available: false, support_mode: 'lxc_only' },
public_ipv4: [{ address: '203.0.113.10', interface: 'eth0' }],
ipv6_prefixes: [],
},
},
'GET /api/v1/routing': {
success: true,
data: {
@@ -1016,6 +1110,12 @@ const responseSamples: Record<string, unknown> = {
load15: 0.01,
},
},
'GET /api/v1/containers/{id}/history': {
success: true,
data: [
{ ts: 1784642400000, cpu: 1.2, memory: 5.6, network: 4096, network_rx: 3072, network_tx: 1024, disk_io: 8192, disk_read: 2048, disk_write: 6144 },
],
},
'GET /api/v1/containers/{id}/traffic': {
success: true,
data: {
@@ -1036,6 +1136,16 @@ const responseSamples: Record<string, unknown> = {
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
'PUT /api/v1/containers/{id}/public-ipv4': {
success: true,
message: 'Public IPv4 assignments updated',
data: { id: 5, name: 'example-vm', public_ipv4s: ['203.0.113.10'] },
},
'PUT /api/v1/containers/{id}/ipv6-addresses': {
success: true,
message: 'IPv6 assignments updated',
data: { id: 5, name: 'example-vm', ipv6_addresses: ['2001:db8:100::1005'] },
},
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
'POST /api/v1/containers/{id}/port-mappings': {
success: true,
@@ -1100,10 +1210,57 @@ const responseSamples: Record<string, unknown> = {
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
],
},
'GET /api/v1/images/enabled?type=lxc&container={id}': {
success: true,
data: [
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', type: 'lxc', downloaded: true, enabled: true },
],
},
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
'GET /api/v1/storage': {
success: true,
data: {
pools: [
{
id: 'disk-root',
name: 'system (/)',
path: '/var/lib/clicd',
mount_point: '/',
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
enabled: true,
available: true,
free_bytes: 54653493248,
},
],
disks: [
{ name: 'sda2', path: '/dev/sda2', fstype: 'ext4', mount_point: '/', size_bytes: 67331063808, used_bytes: 12677570560, free_bytes: 54653493248 },
],
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
},
},
'PUT /api/v1/storage': {
success: true,
data: {
pools: [{ id: 'disk-root', path: '/var/lib/clicd', mount_point: '/', content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'], enabled: true, available: true }],
disks: [],
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
},
},
'GET /api/v1/task-queue/settings': { success: true, data: { concurrency: 4, active: 1, pending: 2 } },
'PUT /api/v1/task-queue/settings': { success: true, message: '任务队列设置已保存', data: { concurrency: 4, active: 1, pending: 2 } },
'GET /api/v1/ssl': {
success: true,
data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', email: 'admin@example.com', detected_host: 'panel.example.com', certificate: { subject: 'panel.example.com', issuer: "Let's Encrypt", dns_names: ['panel.example.com'], ip_names: [], valid: true } },
},
'PUT /api/v1/ssl': { success: true, message: 'SSL settings saved', data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', needs_restart: true } },
'GET /api/v1/webssh-origins': { success: true, data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
'PUT /api/v1/webssh-origins': { success: true, message: 'Origin allowlist saved', data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
'GET /api/v1/language': { success: true, data: { language: 'zh' } },
'PUT /api/v1/language': { success: true, data: { language: 'zh' } },
'GET /api/v1/security/alerts': { success: true, data: [] },
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
@@ -1182,12 +1339,14 @@ function endpointNoteFor(key: string) {
if (key === 'POST /api/v1/containers') {
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
notes.push('storage_pool_id selects an enabled disk for the runtime. For an LXC with an independent LAN address, set lan_ipv4_mode=dhcp or static and set assign_nat=false; static mode also requires lan_ipv4_address, lan_ipv4_prefix_len, and lan_ipv4_gateway.')
notes.push('allowed_image_ids and image_limit_configured define which downloaded images the container owner may use for reinstall. Include the initial template ID when it should remain reinstallable.')
}
if (key === 'POST /api/v1/containers/{id}/reinstall') {
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
}
if (key === 'POST /api/v1/batch-create') {
notes.push('Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.')
notes.push('Each containers[] item in batch creation supports the same storage, network, image allowlist, and SSH authentication fields as POST /api/v1/containers.')
}
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
@@ -1204,6 +1363,30 @@ function endpointNoteFor(key: string) {
if (key === 'POST /api/v1/routing/ipv4-scan') {
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
}
if (key === 'GET /api/v1/host-history' || key === 'GET /api/v1/containers/{id}/history') {
notes.push('Metrics are collected in the background every 30 seconds, even when the statistics page is closed.')
}
if (key === 'PUT /api/v1/containers/{id}/public-ipv4' || key === 'PUT /api/v1/containers/{id}/ipv6-addresses') {
notes.push('mode accepts random, custom, or clear. random uses count, custom uses addresses, and clear removes all assignments of that address family.')
}
if (key === 'GET /api/v1/images/enabled?type=lxc&container={id}') {
notes.push('type accepts lxc or kvm. Supplying container applies that container image allowlist; omit container when listing images for a new container.')
}
if (key === 'POST /api/v1/containers/{id}/snapshots') {
notes.push('storage_pool_id is optional. The selected pool must be enabled for snapshots; otherwise the server chooses an available snapshot pool by free space and default priority.')
}
if (key === 'PUT /api/v1/storage') {
notes.push('Start from GET /api/v1/storage and submit mounted disks returned by the server. Paths and mount points are server-managed and custom paths are rejected. content_types enables a disk for each workload; only one pool may be the default for each type.')
}
if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins')) {
notes.push('This endpoint requires an API key with admin:access.')
}
if (key === 'PUT /api/v1/task-queue/settings') {
notes.push('concurrency must be between 1 and 16. Tasks targeting the same container are still serialized.')
}
if (key === 'PUT /api/v1/ssl') {
notes.push('mode accepts disabled, letsencrypt, self_signed, or uploaded. uploaded mode uses cert_pem and key_pem. apply_now requests a service restart after saving.')
}
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
+316 -48
View File
@@ -32,6 +32,7 @@ import {
assignIPv6,
APIResponse,
Container,
ContainerMetricPoint as ContainerMetricSample,
ContainerUsage,
createSubUser,
createContainerSnapshot,
@@ -39,16 +40,21 @@ import {
deleteContainerSnapshot,
deletePortMapping,
getContainer,
getContainerHistory,
getContainerSnapshots,
getContainerUsage,
getHostInfo,
getStorageInfo,
getTrafficInfo,
HostInfo,
TrafficInfo,
getEnabledImages,
getFirewall,
PortMapping,
PublicIPv4Info,
FirewallRule,
updatePublicIPv4Assignments,
updateIPv6Assignments,
reinstallContainer,
resetSSHPassword,
restartContainer,
@@ -56,6 +62,7 @@ import {
stopContainer,
Snapshot,
SnapshotSchedule,
StorageInfo,
Template,
updateContainerExpiry,
updateFirewall,
@@ -70,6 +77,7 @@ import {
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import WebSSHViewer from '../components/WebSSHViewer'
import WebVNCViewer from '../components/WebVNCViewer'
import { RingStat } from '../components/RingStats'
@@ -105,6 +113,8 @@ type MappingDraft = {
protocol: string
}
type IPAssignMode = 'clear' | 'random' | 'custom'
const emptyDraft: MappingDraft = {
index: null,
description: '',
@@ -120,6 +130,7 @@ export default function ContainerDetail() {
const navigate = useNavigate()
const dialog = useDialog()
const { isSubUser } = useAuth()
const { t } = useLanguage()
const [container, setContainer] = useState<Container | null>(null)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [usage, setUsage] = useState<ContainerUsage | null>(null)
@@ -133,6 +144,14 @@ export default function ContainerDetail() {
const vncFullscreenRef = useRef<HTMLDivElement>(null)
const [vncFullscreen, setVncFullscreen] = useState(false)
const [showNat, setShowNat] = useState(false)
const [showIPAssign, setShowIPAssign] = useState(false)
const [savingIPAssign, setSavingIPAssign] = useState(false)
const [ipv4AssignMode, setIPv4AssignMode] = useState<IPAssignMode>('clear')
const [ipv4AssignCount, setIPv4AssignCount] = useState(1)
const [ipv4Selected, setIPv4Selected] = useState<string[]>([])
const [ipv6AssignMode, setIPv6AssignMode] = useState<IPAssignMode>('clear')
const [ipv6AssignCount, setIPv6AssignCount] = useState(1)
const [ipv6DraftText, setIPv6DraftText] = useState('')
const [showMappingEditor, setShowMappingEditor] = useState(false)
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
const [editExpiry, setEditExpiry] = useState('')
@@ -167,6 +186,9 @@ export default function ContainerDetail() {
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
const [snapshotBusy, setSnapshotBusy] = useState('')
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(!isSubUser)
const [snapshotStoragePoolID, setSnapshotStoragePoolID] = useState('')
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
const [showFirewall, setShowFirewall] = useState(false)
@@ -209,39 +231,36 @@ export default function ContainerDetail() {
}
}, [containerIdentifier, container?.snapshot_limit])
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
if (!containerIdentifier || !currentContainer) return
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 networkRx = nextUsage.network_rx_bps || 0
const networkTx = nextUsage.network_tx_bps || 0
const diskRead = nextUsage.disk_read_bps || 0
const diskWrite = nextUsage.disk_write_bps || 0
const point: MetricPoint = {
ts: Date.now(),
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
memory: clamp(memoryPct),
network: networkRx + networkTx,
networkRx,
networkTx,
diskIO: diskRead + diskWrite,
diskRead,
diskWrite,
const fetchStorage = useCallback(async () => {
if (isSubUser) {
setStorageLoading(false)
return
}
setStorageLoading(true)
try {
const res = await getStorageInfo()
setStorageInfo(res.data.data || null)
} catch (err) {
console.error('Failed to fetch storage:', err)
setStorageInfo(null)
} finally {
setStorageLoading(false)
}
}, [isSubUser])
setHistory((prev) => {
const cutoff = Date.now() - statsRanges['1w']
const next = [...prev.filter((item) => item.ts >= cutoff), point]
localStorage.setItem(historyKey(currentContainer.uuid || containerIdentifier), JSON.stringify(next))
return next
})
}, [containerIdentifier])
const fetchMetricHistory = useCallback(async () => {
if (!containerIdentifier) return
try {
const res = await getContainerHistory(containerIdentifier)
const points = (res.data.data || []).map(normalizeContainerMetricSample)
if (points.length > 0) {
setHistory(points)
localStorage.setItem(historyKey(container?.uuid || containerIdentifier), JSON.stringify(points))
}
} catch (err) {
console.error('Failed to fetch metric history:', err)
}
}, [containerIdentifier, container?.uuid])
const fetchUsage = useCallback(async () => {
if (!containerIdentifier) return
@@ -249,12 +268,11 @@ export default function ContainerDetail() {
const res = await getContainerUsage(containerIdentifier)
if (res.data.data) {
setUsage(res.data.data)
appendUsagePoint(res.data.data, container)
}
} catch (err) {
console.error('Failed to fetch usage:', err)
}
}, [containerIdentifier, container, appendUsagePoint])
}, [containerIdentifier])
useEffect(() => {
if (!containerIdentifier) return
@@ -297,8 +315,17 @@ export default function ContainerDetail() {
}, [fetchUsage])
useEffect(() => {
if (showSnapshots) fetchSnapshots()
}, [showSnapshots, fetchSnapshots])
fetchMetricHistory()
const timer = window.setInterval(fetchMetricHistory, 30000)
return () => window.clearInterval(timer)
}, [fetchMetricHistory])
useEffect(() => {
if (showSnapshots) {
fetchSnapshots()
fetchStorage()
}
}, [showSnapshots, fetchSnapshots, fetchStorage])
// Poll task status for this container
useEffect(() => {
@@ -523,10 +550,12 @@ export default function ContainerDetail() {
const openReinstall = async () => {
try {
const res = await getEnabledImages(container?.virtualization || 'lxc')
const res = await getEnabledImages(container?.virtualization || 'lxc', containerIdentifier)
if (res.data.data) {
setTemplates(res.data.data)
setSelectedTemplate(res.data.data[0]?.id || '')
const data = res.data.data
setTemplates(data)
const currentTemplate = container?.template || ''
setSelectedTemplate(data.some((template) => template.id === currentTemplate) ? currentTemplate : (data[0]?.id || ''))
}
setReinstallAuthMode('keep')
setReinstallPasswordDraft('')
@@ -641,6 +670,42 @@ export default function ContainerDetail() {
}
}
const openIPAssign = () => {
const currentIPv4 = (container?.public_ipv4s || []).map((item) => item.address).filter(Boolean)
const currentIPv6 = (container?.ipv6_addresses || []).map((item) => item.address).filter(Boolean)
setIPv4Selected(currentIPv4)
setIPv4AssignMode(currentIPv4.length > 0 ? 'custom' : 'clear')
setIPv4AssignCount(Math.max(1, currentIPv4.length || 1))
setIPv6DraftText(currentIPv6.join('\n'))
setIPv6AssignMode(currentIPv6.length > 0 ? 'custom' : 'clear')
setIPv6AssignCount(Math.max(1, currentIPv6.length || 1))
setShowIPAssign(true)
}
const submitIPAssign = async () => {
if (!containerIdentifier) return
setSavingIPAssign(true)
try {
await updatePublicIPv4Assignments(containerIdentifier, {
mode: ipv4AssignMode,
count: Math.max(1, Math.round(ipv4AssignCount || 1)),
addresses: ipv4AssignMode === 'custom' ? ipv4Selected : [],
})
await updateIPv6Assignments(containerIdentifier, {
mode: ipv6AssignMode,
count: Math.max(1, Math.round(ipv6AssignCount || 1)),
addresses: ipv6AssignMode === 'custom' ? splitAddressLines(ipv6DraftText) : [],
})
await fetchContainer()
setShowIPAssign(false)
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('公网 IP 分配失败', error.response?.data?.message || '请检查地址是否可用或已被占用')
} finally {
setSavingIPAssign(false)
}
}
const openAddMapping = () => {
if (isSubUser && container?.policy_blocked) return
setDraft(emptyDraft)
@@ -736,6 +801,10 @@ export default function ContainerDetail() {
const handleCreateSnapshot = async () => {
if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!snapshotStorageReady) {
await dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
return
}
if (isSubUser && snapshots.length >= snapshotQuota) {
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
return
@@ -749,7 +818,7 @@ export default function ContainerDetail() {
}
setSnapshotBusy('create')
try {
await createContainerSnapshot(containerIdentifier)
await createContainerSnapshot(containerIdentifier, { storage_pool_id: snapshotStoragePoolID || undefined })
await Promise.all([fetchSnapshots(), fetchContainer()])
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
@@ -761,6 +830,10 @@ export default function ContainerDetail() {
const openSnapshotSchedule = () => {
if (isSubUser && container?.policy_blocked) return
if (!snapshotStorageReady) {
dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
return
}
setSnapshotScheduleDraft({
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
time: snapshotSchedule?.time || '03:00',
@@ -875,6 +948,7 @@ export default function ContainerDetail() {
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
const publicIPv4s = container.public_ipv4s || []
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
const allocatableIPv4s = mergeIPv4Choices(hostInfo?.network.public_ipv4_addresses || [], publicIPv4s)
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
const ipv6List = (container.ipv6_addresses || [])
.map((item) => item.address)
@@ -885,6 +959,10 @@ export default function ContainerDetail() {
const hasIndependentIPv4 = assignedIPv4List.length > 0
const hasIndependentIPv6 = ipv6List.length > 0
const defaultConnPort = isWindows ? 3389 : 22
const snapshotStoragePools = (storageInfo?.pools || []).filter((pool) =>
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('snapshots')
)
const snapshotStorageReady = isSubUser || snapshotStoragePools.length > 0
let publicEndpoint = '-'
let sshCommand = ''
@@ -1192,22 +1270,35 @@ export default function ContainerDetail() {
<PlainRow label="vCPU" value={`${container.vcpu}`} />
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
<PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
<PlainRow label="网络速率" value={formatDirectionalLimit(t('下行'), networkDownLimit, t('上行'), networkUpLimit, 'Mbps')} />
<PlainRow label="IO 速度" value={formatDirectionalLimit(t('读取'), ioReadLimit, t('写入'), ioWriteLimit, 'MB/s')} />
</Panel>
<Panel title="实时状态">
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
{!isSubUser && ipv6List.length === 0 && (
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
Assign
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText}>
{!isSubUser && (
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
<Pencil className="w-3 h-3" />
</button>
)}
</PlainRow>
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
{!isSubUser && (
<>
{ipv6List.length === 0 && (
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
Assign
</button>
)}
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
<Pencil className="w-3 h-3" />
</button>
</>
)}
</PlainRow>
<PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
<PlainRow label="创建时间" value={container.created_at} />
<PlainRow label="到期时间" value={formatExpiration(container.expires_at)}>
@@ -1440,7 +1531,7 @@ export default function ContainerDetail() {
<div className="flex items-center gap-2">
<button
onClick={openSnapshotSchedule}
disabled={!!snapshotBusy}
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
snapshotSchedule?.enabled
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
@@ -1452,7 +1543,7 @@ export default function ContainerDetail() {
</button>
<button
onClick={handleCreateSnapshot}
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady || (isSubUser && snapshots.length >= snapshotQuota)}
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
>
<Camera className="w-3.5 h-3.5" />
@@ -1462,6 +1553,20 @@ export default function ContainerDetail() {
}
>
<div className="space-y-4">
{storageLoading && !isSubUser && (
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<RefreshCw className="h-4 w-4 animate-spin" />
...
</div>
)}
{!storageLoading && !snapshotStorageReady && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<span></span>
<button onClick={() => { setShowSnapshots(false); navigate('/storage') }} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
</button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
<div>
@@ -1497,6 +1602,26 @@ export default function ContainerDetail() {
)}
</div>
{!isSubUser && snapshotStoragePools.length > 0 && (
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<Field label="新建快照存储磁盘">
<select
value={snapshotStoragePoolID}
onChange={(event) => setSnapshotStoragePoolID(event.target.value)}
className="w-72 px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black"
>
<option value=""></option>
{snapshotStoragePools.map((pool) => (
<option key={pool.id} value={pool.id}>
{pool.name} · {pool.mount_point || pool.path}
</option>
))}
</select>
</Field>
<div className="pb-2 text-xs text-gray-400">使</div>
</div>
)}
{editingSnapshotQuota && !isSubUser && (
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<Field label="子用户每台容器快照上限">
@@ -1828,6 +1953,88 @@ export default function ContainerDetail() {
</Modal>
)}
{showIPAssign && (
<Modal title="公网 IP 分配" onClose={() => setShowIPAssign(false)} wide>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-gray-900"> IPv4</h3>
<p className="mt-1 text-xs text-gray-500">SNAT </p>
</div>
<Segmented value={ipv4AssignMode} onChange={setIPv4AssignMode} />
{ipv4AssignMode === 'random' && (
<Field label="随机数量">
<input type="number" min={1} max={64} value={ipv4AssignCount} onChange={(e) => setIPv4AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
</Field>
)}
{ipv4AssignMode === 'custom' && (
<div className="space-y-2">
{allocatableIPv4s.length === 0 ? (
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500"> IPv4 IPv4 </div>
) : (
<div className="grid gap-2">
{allocatableIPv4s.map((ip) => (
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-xs text-gray-700">
<input
type="checkbox"
checked={ipv4Selected.includes(ip.address)}
onChange={(event) => {
const next = event.target.checked
? Array.from(new Set([...ipv4Selected, ip.address]))
: ipv4Selected.filter((value) => value !== ip.address)
setIPv4Selected(next)
setIPv4AssignCount(Math.max(1, next.length || 1))
}}
/>
<span className="truncate font-mono">{ip.address}</span>
<span className="shrink-0 text-gray-400">{ip.interface}</span>
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
</label>
))}
</div>
)}
</div>
)}
</div>
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-gray-900"> IPv6</h3>
<p className="mt-1 text-xs text-gray-500"> IPv6 </p>
</div>
<Segmented value={ipv6AssignMode} onChange={setIPv6AssignMode} />
{ipv6AssignMode === 'random' && (
<Field label="随机数量">
<input type="number" min={1} max={64} value={ipv6AssignCount} onChange={(e) => setIPv6AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
</Field>
)}
{ipv6AssignMode === 'custom' && (
<Field label="IPv6 地址">
<textarea
value={ipv6DraftText}
onChange={(e) => {
setIPv6DraftText(e.target.value)
setIPv6AssignCount(Math.max(1, splitAddressLines(e.target.value).length || 1))
}}
className={`${inputClass} min-h-32 font-mono text-xs`}
placeholder="2001:db8:100::100&#10;2001:db8:100::101"
/>
</Field>
)}
</div>
</div>
<div className="mt-5 flex justify-end gap-2 border-t border-gray-200 pt-4">
<button onClick={() => setShowIPAssign(false)} disabled={savingIPAssign} className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50">
</button>
<button onClick={submitIPAssign} disabled={savingIPAssign} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-4 w-4" />
{savingIPAssign ? '保存中...' : '保存'}
</button>
</div>
</Modal>
)}
{showNat && !hasIndependentIPv4 && (
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
!isSubUser && canAddMapping && (
@@ -2453,6 +2660,28 @@ function Field({ label, children, hint }: { label: string; children: ReactNode;
)
}
function Segmented({ value, onChange }: { value: IPAssignMode; onChange: (value: IPAssignMode) => void }) {
const items: Array<{ value: IPAssignMode; label: string }> = [
{ value: 'clear', label: '不分配' },
{ value: 'random', label: '随机分配' },
{ value: 'custom', label: '自定义' },
]
return (
<div className="grid grid-cols-3 gap-1 rounded-md bg-gray-100 p-1">
{items.map((item) => (
<button
key={item.value}
type="button"
onClick={() => onChange(item.value)}
className={`rounded px-2 py-1.5 text-xs font-medium ${value === item.value ? 'bg-white text-black shadow-sm' : 'text-gray-600 hover:text-black'}`}
>
{item.label}
</button>
))}
</div>
)
}
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">
@@ -2490,6 +2719,45 @@ function readHistory(containerName: string): MetricPoint[] {
}
}
function normalizeContainerMetricSample(point: ContainerMetricSample): MetricPoint {
return {
ts: point.ts,
cpu: clamp(point.cpu),
memory: clamp(point.memory),
network: point.network || 0,
networkRx: point.network_rx || 0,
networkTx: point.network_tx || 0,
diskIO: point.disk_io || 0,
diskRead: point.disk_read || 0,
diskWrite: point.disk_write || 0,
}
}
function splitAddressLines(value: string) {
return value
.split(/[\n,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function mergeIPv4Choices(candidates: PublicIPv4Info[], assigned: { address: string; interface?: string; prefix_len?: number; gateway?: string }[]) {
const byAddress = new Map<string, PublicIPv4Info>()
for (const item of candidates) {
if (item.address) byAddress.set(item.address, item)
}
for (const item of assigned) {
if (!item.address || byAddress.has(item.address)) continue
byAddress.set(item.address, {
address: item.address,
interface: item.interface || '',
prefix: item.prefix_len ? `${item.address}/${item.prefix_len}` : item.address,
prefix_len: item.prefix_len,
gateway: item.gateway,
})
}
return Array.from(byAddress.values()).sort((a, b) => a.address.localeCompare(b.address, undefined, { numeric: true }))
}
function historyKey(containerName: string) {
return `clicd_container_metric_history:${containerName}`
}
+38 -26
View File
@@ -21,6 +21,7 @@ import {
} from 'lucide-react'
import CreateContainerModal from '../components/CreateContainerModal'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import {
Container,
CreateContainerRequest,
@@ -391,7 +392,7 @@ export default function Containers() {
{pageContainers.map((container) => {
const isRunning = container.status === 'running'
const isInitializing = container.status === 'initializing'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const task = (container.id > 0 ? taskStatusMap[container.id] : undefined) || taskNameMap[container.name] || container.createTask
const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name]
@@ -581,12 +582,13 @@ type DisplayContainer = Container & {
}
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
const { t } = useLanguage()
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>
{t('策略封禁')}
</span>
)
}
@@ -595,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, 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>
{t('初始化失败')}
</span>
)
}
@@ -604,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
{t('初始化完成')}
</span>
)
}
if (task?.type === 'create' && task.status === 'running') {
const detail = t(task.stage_detail || '正在初始化')
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
<span className={`${baseClass} max-w-[210px] bg-amber-50 text-amber-700`} title={`${t('正在初始化')}: ${detail}`}>
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-500 animate-pulse"></span>
<span className="truncate">{detail}</span>
</span>
)
}
@@ -622,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
{t('排队等待')}
</span>
)
}
@@ -634,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{taskLabels[task.type] || '处理中'}
{t(taskLabels[task.type] || '处理中')}
</span>
)
}
@@ -643,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{t('正在初始化')}
</span>
)
}
@@ -651,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
return (
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
{running ? '在线' : '离线'}
{t(running ? '在线' : '离线')}
</span>
)
}
@@ -788,7 +791,7 @@ type ContainerFilters = {
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
const keyword = filters.search.trim().toLowerCase()
return containers.filter((container) => {
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : undefined) || filters.taskNameMap[container.name] || container.createTask
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
return false
}
@@ -865,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task)
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
if (task.type === 'create' && task.status === 'done') return '初始化完成'
if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化'
return actionLabels[task.type] || '处理中...'
}
@@ -873,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
onRefresh: () => void | Promise<void>
onClose: () => void
}) {
const { t } = useLanguage()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex max-h-[86vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="text-base font-semibold text-black"></h2>
<p className="mt-0.5 text-xs text-gray-500"> {tasks.length} </p>
<h2 className="text-base font-semibold text-black">{t('任务队列')}</h2>
<p className="mt-0.5 text-xs text-gray-500">{t(`${tasks.length} 个任务`)}</p>
</div>
<div className="flex items-center gap-2">
<button
@@ -887,26 +892,27 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<RefreshCw className="h-4 w-4" />
{t('刷新')}
</button>
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title={t('关闭')}>
<X className="h-4 w-4" />
</button>
</div>
</div>
{tasks.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
<div className="p-8 text-center text-sm text-gray-500">{t('暂无任务')}</div>
) : (
<div className="overflow-auto">
<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="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5">{t('状态')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('操作')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('容器')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('当前阶段')}</th>
<th className="whitespace-nowrap px-4 py-2.5">{t('创建时间')}</th>
<th className="px-4 py-2.5">{t('错误')}</th>
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
</tr>
</thead>
@@ -915,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
<tr key={task.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-2.5">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
{taskStatusLabel(task.status)}
{t(taskStatusLabel(task.status))}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{t(actionLabel(task.type))}</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
<td className="min-w-[210px] px-4 py-2.5 text-xs text-gray-700">
{task.type === 'create' ? t(task.stage_detail || (task.status === 'pending' ? '排队等待' : '-')) : '-'}
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
<td className="whitespace-nowrap px-2 py-2.5">
@@ -932,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
} catch { /* ignore */ }
}}
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
title="取消任务"
title={t('取消任务')}
>
<X className="w-3.5 h-3.5" />
</button>
@@ -967,6 +976,7 @@ function getTemplateName(id: string) {
const map: Record<string, string> = {
'ubuntu-noble': 'Ubuntu 24.04',
'ubuntu-jammy': 'Ubuntu 22.04',
'debian-trixie': 'Debian 13',
'debian-bookworm': 'Debian 12',
'debian-bullseye': 'Debian 11',
'alpine-3.21': 'Alpine 3.21',
@@ -976,6 +986,8 @@ function getTemplateName(id: string) {
'rockylinux-10': 'Rocky 10',
'kvm-ubuntu-noble': 'Ubuntu 24.04',
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
'kvm-debian-trixie': 'Debian 13',
'kvm-debian-trixie-xfce': 'Debian 13 XFCE',
'kvm-debian-bookworm': 'Debian 12',
'kvm-debian-bullseye': 'Debian 11',
'kvm-rockylinux-9': 'Rocky 9',
+35 -29
View File
@@ -7,7 +7,7 @@ import ResourceStatsPanel, {
StatsRangeKey,
statsRanges,
} from '../components/ResourceStatsPanel'
import { DashboardStats, getDashboard, getHostInfo, HostInfo } from '../services/api'
import { DashboardStats, getDashboard, getHostHistory, getHostInfo, HostInfo, HostMetricPoint as HostMetricSample } from '../services/api'
type HostMetricPoint = {
ts: number
@@ -30,6 +30,19 @@ export default function Dashboard() {
const [range, setRange] = useState<StatsRangeKey>('30m')
const [loading, setLoading] = useState(true)
const fetchHistory = useCallback(async () => {
try {
const res = await getHostHistory()
const points = (res.data.data || []).map(normalizeHostMetricSample)
if (points.length > 0) {
setHistory(points)
localStorage.setItem(hostHistoryKey, JSON.stringify(points))
}
} catch (err) {
console.error(err)
}
}, [])
const fetchData = useCallback(async () => {
try {
const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()])
@@ -37,7 +50,6 @@ export default function Dashboard() {
if (hostRes.data.data) {
const nextHost = hostRes.data.data
setHost(nextHost)
appendHostPoint(nextHost, setHistory)
}
} catch (err) {
console.error(err)
@@ -47,10 +59,15 @@ export default function Dashboard() {
}, [])
useEffect(() => {
fetchHistory()
fetchData()
const interval = window.setInterval(fetchData, 5000)
return () => window.clearInterval(interval)
}, [fetchData])
const historyInterval = window.setInterval(fetchHistory, 30000)
return () => {
window.clearInterval(interval)
window.clearInterval(historyInterval)
}
}, [fetchData, fetchHistory])
if (loading) {
return (
@@ -172,31 +189,6 @@ function SummaryCard({
)
}
function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) {
const networkRx = host.network.rx_bps || 0
const networkTx = host.network.tx_bps || 0
const diskRead = host.disk_io.read_bps || 0
const diskWrite = host.disk_io.write_bps || 0
const point: HostMetricPoint = {
ts: Date.now(),
cpu: clamp(host.cpu.usage_pct),
memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0,
network: networkRx + networkTx,
networkRx,
networkTx,
diskIO: diskRead + diskWrite,
diskRead,
diskWrite,
}
setHistory((prev) => {
const cutoff = Date.now() - statsRanges['1w']
const next = [...prev.filter((item) => item.ts >= cutoff), point]
localStorage.setItem(hostHistoryKey, JSON.stringify(next))
return next
})
}
function readHostHistory(): HostMetricPoint[] {
try {
const raw = localStorage.getItem(hostHistoryKey)
@@ -221,6 +213,20 @@ function toChartPoints(history: HostMetricPoint[], key: keyof Omit<HostMetricPoi
})
}
function normalizeHostMetricSample(point: HostMetricSample): HostMetricPoint {
return {
ts: point.ts,
cpu: clamp(point.cpu),
memory: clamp(point.memory),
network: point.network || 0,
networkRx: point.network_rx || 0,
networkTx: point.network_tx || 0,
diskIO: point.disk_io || 0,
diskRead: point.disk_read || 0,
diskWrite: point.disk_write || 0,
}
}
function clamp(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(value, 100))
+60 -7
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Download,
Trash2,
@@ -11,15 +12,18 @@ import {
AlertCircle,
X,
} from 'lucide-react'
import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo, StorageInfo } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function ImageManagement() {
const dialog = useDialog()
const navigate = useNavigate()
const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState('')
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(true)
const fetchImages = useCallback(async () => {
try {
@@ -33,9 +37,22 @@ export default function ImageManagement() {
}
}, [])
const fetchStorage = useCallback(async () => {
setStorageLoading(true)
try {
const res = await getStorageInfo()
setStorageInfo(res.data.data || null)
} catch {
setStorageInfo(null)
} finally {
setStorageLoading(false)
}
}, [])
useEffect(() => {
fetchImages()
}, [fetchImages])
fetchStorage()
}, [fetchImages, fetchStorage])
useEffect(() => {
const hasDownloads = images.some((img) => img.downloading)
@@ -101,6 +118,9 @@ export default function ImageManagement() {
const downloadedCount = images.filter((img) => img.downloaded).length
const lxcImages = images.filter((img) => img.type === 'lxc')
const kvmImages = images.filter((img) => img.type === 'kvm')
const imageStorageReady = (storageInfo?.pools || []).some((pool) =>
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('images')
)
if (loading) {
return (
@@ -121,7 +141,7 @@ export default function ImageManagement() {
</p>
</div>
<button
onClick={fetchImages}
onClick={() => { fetchImages(); fetchStorage() }}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
>
<RefreshCw className="w-3.5 h-3.5" />
@@ -136,6 +156,25 @@ export default function ImageManagement() {
</div>
)}
{storageLoading && (
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
...
</div>
)}
{!storageLoading && !imageStorageReady && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 shrink-0" />
</div>
<button onClick={() => navigate('/storage')} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
</button>
</div>
)}
<ImageTable
title="LXC 容器镜像"
images={lxcImages}
@@ -146,6 +185,8 @@ export default function ImageManagement() {
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
storageReady={imageStorageReady}
storageLoading={storageLoading}
/>
{kvmImages.length > 0 && (
@@ -159,6 +200,8 @@ export default function ImageManagement() {
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
storageReady={imageStorageReady}
storageLoading={storageLoading}
/>
)}
</div>
@@ -175,6 +218,8 @@ function ImageTable({
onCancelDownload,
onDelete,
onToggle,
storageReady,
storageLoading,
}: {
title: string
images: ImageInfo[]
@@ -185,6 +230,8 @@ function ImageTable({
onCancelDownload: (id: string) => void
onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void
storageReady: boolean
storageLoading: boolean
}) {
return (
<div className="space-y-3">
@@ -253,7 +300,8 @@ function ImageTable({
{!img.downloaded && !img.downloading && (
<button
onClick={() => onDownload(img.id)}
disabled={isBusy}
disabled={isBusy || storageLoading || !storageReady}
title={storageLoading ? '正在检查存储配置...' : storageReady ? '下载镜像' : '请先在存储管理中开启镜像缓存存储'}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
>
{isBusy ? (
@@ -281,7 +329,8 @@ function ImageTable({
<>
<button
onClick={() => onToggle(img.id, img.enabled)}
disabled={isBusy}
disabled={isBusy || storageLoading || !storageReady}
title={storageLoading ? '正在检查存储配置...' : storageReady ? (img.enabled ? '禁用镜像' : '启用镜像') : '请先在存储管理中开启镜像缓存存储'}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
img.enabled
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
@@ -317,7 +366,7 @@ function ImageTable({
function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) {
const progress = Math.max(0, Math.min(100, img.progress || 0))
const showProgress = img.stage === 'downloading' && progress > 0
const showProgress = img.stage === 'downloading' && (progress > 0 || img.downloaded_bytes > 0)
return (
<div className="inline-flex flex-col gap-1">
<span
@@ -329,7 +378,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
</span>
{showProgress && (
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
<span
className={`block h-full rounded-full bg-amber-500 transition-all ${progress <= 0 ? 'animate-pulse' : ''}`}
style={{ width: progress > 0 ? `${progress}%` : '35%' }}
/>
</span>
)}
</div>
@@ -375,6 +427,7 @@ function downloadStatusLabel(img: ImageInfo) {
if (img.stage === 'converting') return '转换中'
if (img.stage === 'lxc-create') return '下载中'
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
if (img.downloaded_bytes > 0) return `下载中 · ${formatSize(img.downloaded_bytes)}`
return '下载中'
}
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.23</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.26</p>
</div>
</div>
)
+54 -1
View File
@@ -9,6 +9,7 @@ import {
updateRoutingPools,
type IPv4Route,
type IPv6Route,
type LANDHCPRoute,
type IPv6PrefixInfo,
type NAT4PortRange,
type NAT4Route,
@@ -56,6 +57,7 @@ export default function Routing() {
const publicIPv4s = routing?.public_ipv4_addresses || []
const ipv4Assignments = routing?.ipv4_assignments || []
const lanDHCPAssignments = routing?.lan_dhcp_assignments || []
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Prefixes = routing?.ipv6_prefixes || []
const ipv6Assignments = routing?.ipv6_assignments || []
@@ -276,7 +278,7 @@ export default function Routing() {
</button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-4">
<CapacityCard
title={text.nat4Ports}
watermark="NAT4"
@@ -293,6 +295,7 @@ export default function Routing() {
}
/>
<CapacityCard title={text.publicIPv4} watermark="IPv4" remaining={routing?.ipv4.remaining || '0'} total={routing?.ipv4.total || '0'} used={routing?.ipv4.used || 0} label={formatPoolCount(publicIPv4s.length, language)} usedLabel={text.used} />
<CapacityCard title={text.lanDHCP} watermark="LAN" remaining={String(routing?.lan_dhcp.used || 0)} total={routing?.lan_dhcp.total || 'DHCP'} used={routing?.lan_dhcp.used || 0} label={text.dhcpManagedByLAN} usedLabel={text.used} />
<CapacityCard title="IPv6" watermark="IPv6" remaining={formatCapacity(routing?.ipv6.remaining || '0', language)} total={formatCapacity(routing?.ipv6.total || '0', language)} used={routing?.ipv6.used || 0} label={formatDetectedPrefixCount(ipv6Prefixes.length, language)} usedLabel={text.used} />
</div>
@@ -541,6 +544,48 @@ export default function Routing() {
</RouteModal>
)}
<Panel title={text.lanDHCPAssignments} subtitle={formatAddressSubtitle(lanDHCPAssignments.length, lanDHCPAssignments.length, language)}>
{lanDHCPAssignments.length === 0 ? (
<EmptyState text={text.noLANDHCPAssignments} icon={<Network className="h-7 w-7" />} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-4 py-3 text-left font-medium">{text.container}</th>
<th className="px-4 py-3 text-left font-medium">{text.runtimeName}</th>
<th className="px-4 py-3 text-left font-medium">{text.guestIPv4}</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium">{text.gateway}</th>
<th className="px-4 py-3 text-left font-medium">MAC</th>
<th className="px-4 py-3 text-left font-medium">{text.interface}</th>
<th className="px-4 py-3 text-left font-medium">{text.status}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{lanDHCPAssignments.map((item: LANDHCPRoute) => (
<tr key={`${item.container_id}-${item.interface}-${item.mac_address || item.address}`} className="hover:bg-gray-50">
<td className="px-4 py-3">
<button onClick={() => navigate(`/container/${item.container_id}`)} className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline">
<Server className="h-4 w-4 text-gray-400" />
{item.container_name}
</button>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.lxc_name}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-700">{item.address ? `${item.address}${item.prefix_len ? `/${item.prefix_len}` : ''}` : '-'}</td>
<td className="px-4 py-3 text-xs text-gray-600">{item.mode === 'static' ? '手动' : 'DHCP'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.gateway || '-'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.mac_address || '-'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.interface || '-'}</td>
<td className="px-4 py-3"><StatusBadge status={item.status} language={language} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Panel>
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
{nat4Mappings.length === 0 ? (
<EmptyState text={text.noIPv4NATMappings} icon={<Network className="h-7 w-7" />} />
@@ -851,6 +896,10 @@ const routingText = {
saveNAT4RangeFailed: '保存 NAT4 范围失败',
remainingTotal: '剩余 / 总数',
publicIPv4: '公网 IPv4',
lanDHCP: '局域网 DHCP',
dhcpManagedByLAN: '由局域网 DHCP 分配',
lanDHCPAssignments: '局域网 DHCP 分配',
noLANDHCPAssignments: '暂无局域网 DHCP 分配',
publicIPv4Pool: '公网 IPv4 池',
editPool: '编辑 IP 池',
noPublicIPv4Pool: '暂未配置公网 IPv4 池',
@@ -922,6 +971,10 @@ const routingText = {
saveNAT4RangeFailed: 'Save NAT4 range failed',
remainingTotal: 'remaining / total',
publicIPv4: 'Public IPv4',
lanDHCP: 'LAN DHCP',
dhcpManagedByLAN: 'Managed by LAN DHCP',
lanDHCPAssignments: 'LAN DHCP assignments',
noLANDHCPAssignments: 'No LAN DHCP assignments',
publicIPv4Pool: 'Public IPv4 pool',
editPool: 'Edit pool',
noPublicIPv4Pool: 'No public IPv4 pool configured',
+233 -73
View File
@@ -1,23 +1,38 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import { Clock, Globe, ListTodo, Lock, LogIn, Minus, Monitor, Plus, RefreshCw, Save, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
getTaskQueueSettings,
getWebSSHOriginSettings,
LoginLog,
SSLSettings,
TaskQueueSettings,
updateTaskQueueSettings,
updateSSLSettings,
updateWebSSHOriginSettings,
WebSSHOriginSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
type SettingsSection = 'tasks' | 'account' | 'webssh' | 'ssl' | 'logs'
const settingsSections = [
{ id: 'tasks', label: '任务队列', icon: ListTodo },
{ id: 'account', label: '账号设置', icon: UserCog },
{ id: 'webssh', label: 'WebSSH 访问', icon: Terminal },
{ id: 'ssl', label: 'SSL 证书', icon: ShieldCheck },
{ id: 'logs', label: '登录日志', icon: LogIn },
] as const
export default function Settings() {
const dialog = useDialog()
const { username } = useAuth()
const { t } = useLanguage()
const [logs, setLogs] = useState<LoginLog[]>([])
const [loading, setLoading] = useState(true)
const [logPage, setLogPage] = useState(1)
@@ -39,6 +54,10 @@ export default function Settings() {
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
const [taskQueue, setTaskQueue] = useState<TaskQueueSettings | null>(null)
const [taskConcurrency, setTaskConcurrency] = useState(2)
const [savingTaskQueue, setSavingTaskQueue] = useState(false)
const [activeSection, setActiveSection] = useState<SettingsSection>('tasks')
const fetchLogs = useCallback(async () => {
try {
@@ -78,13 +97,49 @@ export default function Settings() {
}
}, [])
const fetchTaskQueue = useCallback(async () => {
try {
const res = await getTaskQueueSettings()
const data = res.data.data
if (!data) return
setTaskQueue(data)
setTaskConcurrency(data.concurrency)
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
fetchWebSSHOrigins()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
fetchTaskQueue()
const logTimer = setInterval(fetchLogs, 15000)
const taskTimer = setInterval(fetchTaskQueue, 5000)
return () => {
clearInterval(logTimer)
clearInterval(taskTimer)
}
}, [fetchLogs, fetchSSL, fetchTaskQueue, fetchWebSSHOrigins])
const handleSaveTaskQueue = async () => {
const concurrency = Math.max(1, Math.min(16, Math.round(taskConcurrency || 1)))
setSavingTaskQueue(true)
try {
const res = await updateTaskQueueSettings(concurrency)
const data = res.data.data
if (data) {
setTaskQueue(data)
setTaskConcurrency(data.concurrency)
}
dialog.alert('完成', '任务队列并发设置已保存并立即生效')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || '任务队列设置保存失败')
} finally {
setSavingTaskQueue(false)
}
}
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
@@ -190,72 +245,173 @@ export default function Settings() {
const totalPages = Math.ceil(logs.length / pageSize)
return (
<div className="space-y-6">
<div className="space-y-5">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
<h1 className="text-2xl font-bold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">访</p>
</div>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<div className="space-y-6">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="grid items-start gap-4 lg:grid-cols-[210px_minmax(0,1fr)]">
<aside className="overflow-x-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-900 lg:sticky lg:top-4">
<nav className="flex min-w-max gap-1 lg:min-w-0 lg:flex-col" aria-label="设置分类">
{settingsSections.map((section) => {
const Icon = section.icon
const active = activeSection === section.id
return (
<button
key={section.id}
type="button"
onClick={() => setActiveSection(section.id)}
className={`flex items-center gap-2 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors ${active ? 'bg-black text-white dark:bg-white dark:text-black' : 'text-gray-600 hover:bg-gray-100 hover:text-black dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white'}`}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span>{t(section.label)}</span>
</button>
)
})}
</nav>
</aside>
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
<section className="min-w-0">
{activeSection === 'tasks' && (
<TaskQueueCard
settings={taskQueue}
concurrency={taskConcurrency}
saving={savingTaskQueue}
onConcurrencyChange={setTaskConcurrency}
onRefresh={fetchTaskQueue}
onSave={handleSaveTaskQueue}
/>
)}
{activeSection === 'account' && (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<UserCog className="h-4 w-4" />
</h2>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 3 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="输入当前密码以确认修改" />
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={handleSaveAccount} className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"></button>
</div>
</div>
)}
{activeSection === 'webssh' && (
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
)}
{activeSection === 'ssl' && (
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
)}
{activeSection === 'logs' && (
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
)}
</section>
</div>
</div>
)
}
interface TaskQueueCardProps {
settings: TaskQueueSettings | null
concurrency: number
saving: boolean
onConcurrencyChange: (value: number) => void
onRefresh: () => void
onSave: () => void
}
function TaskQueueCard(props: TaskQueueCardProps) {
const setBounded = (value: number) => props.onConcurrencyChange(Math.max(1, Math.min(16, value)))
return (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ListTodo className="h-4 w-4" />
</h2>
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 divide-x divide-gray-200 border-y border-gray-100 bg-gray-50">
<div className="px-3 py-2">
<div className="text-[11px] text-gray-500"></div>
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.active ?? 0}</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
<UserCog className="h-4 w-4" />
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"></button>
</div>
<div className="px-3 py-2">
<div className="text-[11px] text-gray-500"></div>
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.pending ?? 0}</div>
</div>
</div>
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
<div className="mt-4">
<label className="mb-1.5 block text-xs text-gray-500"></label>
<div className="flex h-9 items-stretch">
<button type="button" onClick={() => setBounded(props.concurrency - 1)} disabled={props.concurrency <= 1} className="flex w-10 items-center justify-center rounded-l-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="减少并发">
<Minus className="h-4 w-4" />
</button>
<input
type="number"
min={1}
max={16}
value={props.concurrency}
onChange={(event) => setBounded(Number(event.target.value) || 1)}
className="min-w-0 flex-1 border-y border-gray-300 px-2 text-center text-sm font-medium text-black outline-none focus:ring-2 focus:ring-inset focus:ring-black"
/>
<button type="button" onClick={() => setBounded(props.concurrency + 1)} disabled={props.concurrency >= 16} className="flex w-10 items-center justify-center rounded-r-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="增加并发">
<Plus className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-4 w-4" />
{props.saving ? '保存中...' : '保存队列设置'}
</button>
</div>
</div>
)
}
@@ -292,7 +448,7 @@ interface WebSSHOriginCardProps {
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<Terminal className="h-4 w-4" />WebSSH Origin
@@ -307,7 +463,7 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
<textarea
value={props.originsText}
onChange={(e) => props.onOriginsTextChange(e.target.value)}
rows={5}
rows={4}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
/>
</div>
@@ -315,10 +471,12 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>{props.settings?.current_origin || '-'}</div>
<div className="mt-1"> Origin</div>
</div>
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.saving ? '保存中...' : '保存 Origin 白名单'}
</button>
<div className="flex justify-end">
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.saving ? '保存中...' : '保存 Origin 白名单'}
</button>
</div>
</div>
</div>
)
@@ -425,10 +583,12 @@ function SSLCard(props: SSLCardProps) {
</label>
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
<div className="flex justify-end">
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
</button>
</div>
</div>
</div>
)
+369
View File
@@ -0,0 +1,369 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertCircle, CheckCircle2, HardDrive, RefreshCw, Save } from 'lucide-react'
import { getStorageInfo, updateStoragePools, StorageDisk, StorageInfo, StoragePool } from '../services/api'
import { useLanguage } from '../contexts/LanguageContext'
const contentOptions = [
['lxc', 'LXC 容器'],
['kvm', 'KVM 磁盘'],
['images', '镜像缓存'],
['snapshots', '快照'],
['backups', '备份'],
] as const
const contentLabels = Object.fromEntries(contentOptions)
const contentColors: Record<string, string> = {
lxc: '#2563eb',
kvm: '#7c3aed',
images: '#d97706',
snapshots: '#059669',
backups: '#0891b2',
}
export default function Storage() {
const { t } = useLanguage()
const [info, setInfo] = useState<StorageInfo | null>(null)
const [pools, setPools] = useState<StoragePool[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await getStorageInfo()
const data = res.data.data || { pools: [], disks: [], content_types: [] }
setInfo(data)
setPools(data.pools || [])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
useEffect(() => {
if (!saveMessage) return
const timer = window.setTimeout(() => setSaveMessage(null), 3500)
return () => window.clearTimeout(timer)
}, [saveMessage])
const mountedDisks = useMemo(() => (info?.disks || []).filter((disk) => !!disk.mount_point), [info?.disks])
const save = async () => {
setSaveMessage(null)
setSaving(true)
try {
const normalized = pools
.map((pool) => ({
...pool,
id: (pool.id || pool.name || '').trim(),
name: (pool.name || '').trim(),
path: (pool.path || '').trim(),
content_types: pool.content_types || [],
default_contents: (pool.default_contents || []).filter((item) => (pool.content_types || []).includes(item)),
enabled: pool.enabled !== false,
}))
const res = await updateStoragePools(normalized)
const data = res.data.data
if (data) {
setInfo(data)
setPools(data.pools || [])
}
setSaveMessage({ type: 'success', text: '存储配置已保存' })
} catch (err: any) {
setSaveMessage({ type: 'error', text: err?.response?.data?.message || '保存存储配置失败' })
} finally {
setSaving(false)
}
}
const updateDiskPool = (disk: StorageDisk, updater: (pool: StoragePool) => StoragePool) => {
setPools((current) => {
const index = current.findIndex((pool) => poolForDisk(pool, disk))
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
const nextPool = updater(base)
if (index >= 0) {
return current.map((item, i) => i === index ? nextPool : item)
}
return [...current, nextPool]
})
}
const toggleContent = (disk: StorageDisk, content: string) => {
updateDiskPool(disk, (pool) => {
const current = pool.content_types || []
const enabled = current.includes(content)
const contentTypes = enabled ? current.filter((item) => item !== content) : [...current, content]
return {
...pool,
enabled: true,
content_types: contentTypes,
default_contents: (pool.default_contents || []).filter((item) => contentTypes.includes(item)),
}
})
}
const toggleDefault = (disk: StorageDisk, content: string) => {
setPools((current) => {
const index = current.findIndex((pool) => poolForDisk(pool, disk))
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
if (!(base.content_types || []).includes(content)) return current
const hasDefault = (base.default_contents || []).includes(content)
const baseDefaults = (base.default_contents || []).filter((value) => value !== content)
const cleared = current.map((item) => ({
...item,
default_contents: (item.default_contents || []).filter((value) => value !== content),
}))
const nextPool = {
...base,
default_contents: hasDefault ? baseDefaults : [...baseDefaults, content],
}
if (index >= 0) {
return cleared.map((item, i) => i === index ? nextPool : item)
}
return [...cleared, nextPool]
})
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
</div>
)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-black dark:text-white">{t('存储管理')}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t('只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。')}</p>
</div>
<div className="flex gap-2">
<button onClick={fetchData} className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
<RefreshCw className="h-4 w-4" />{t('刷新')}
</button>
<button onClick={save} disabled={saving} className="inline-flex items-center gap-2 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-4 w-4" />{t(saving ? '保存中...' : '保存')}
</button>
</div>
</div>
{saveMessage && (
<div
role="status"
aria-live="polite"
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
saveMessage.type === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-red-200 bg-red-50 text-red-700'
}`}
>
{saveMessage.type === 'success'
? <CheckCircle2 className="h-4 w-4 shrink-0" />
: <AlertCircle className="h-4 w-4 shrink-0" />}
<span>{t(saveMessage.text)}</span>
</div>
)}
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full min-w-[1240px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-4 py-3 text-left font-medium">{t('磁盘')}</th>
<th className="px-4 py-3 text-left font-medium">{t('空间分布')}</th>
<th className="px-4 py-3 text-left font-medium">{t('用于存储')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{mountedDisks.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400">{t('未检测到已挂载磁盘')}</td></tr>
) : mountedDisks.map((disk) => {
const pool = pools.find((item) => poolForDisk(item, disk))
const contentUsage = contentUsageMap(pool?.content_usage || disk.content_usage || [])
const clicdUsed = pool?.clicd_used_bytes || disk.clicd_used_bytes || 0
return (
<tr key={`${disk.path}-${disk.mount_point}`} className="align-top hover:bg-gray-50/70">
<td className="px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-md bg-gray-100 text-gray-600">
<HardDrive className="h-5 w-5" />
</div>
<div>
<div className="font-mono text-xs font-medium text-gray-900">{disk.path || disk.name}</div>
<div className="mt-1 text-xs text-gray-500">{disk.model || disk.fstype || disk.type || '-'}</div>
<div className="mt-1 font-mono text-xs text-gray-400">{disk.mount_point}</div>
</div>
</div>
</td>
<td className="px-4 py-4">
<DiskUsageBar disk={disk} contentUsage={contentUsage} clicdUsed={clicdUsed} />
</td>
<td className="px-4 py-4">
<div className="flex min-w-[620px] flex-nowrap items-start gap-2">
{contentOptions.map(([value, label]) => {
const checked = (pool?.content_types || []).includes(value)
const isDefault = (pool?.default_contents || []).includes(value)
return (
<div key={value} className={`w-[116px] shrink-0 rounded-md border px-2.5 py-2 ${checked ? 'border-gray-300 bg-white' : 'border-gray-200 bg-gray-50'}`}>
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-700">
<input type="checkbox" checked={checked} onChange={() => toggleContent(disk, value)} />
{t(label)}
</label>
{checked && (
<div className="mt-1.5 flex items-center justify-between gap-2 border-t border-gray-100 pt-1.5">
<span className="text-[11px] text-gray-500">{t('默认盘')}</span>
<button
type="button"
role="switch"
aria-checked={isDefault}
title={isDefault ? `${t('关闭')} ${t(label)} ${t('默认盘')}` : `${t('设为')} ${t(label)} ${t('默认盘')}`}
onClick={() => toggleDefault(disk, value)}
className={`relative inline-flex h-5 w-9 shrink-0 appearance-none items-center rounded-full border p-0 transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 ${isDefault ? 'border-black bg-black' : 'border-gray-300 bg-gray-200'}`}
>
<span className={`pointer-events-none absolute left-0.5 top-0.5 block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${isDefault ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</div>
)}
</div>
)
})}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
function DiskUsageBar({
disk,
contentUsage,
clicdUsed,
}: {
disk: StorageDisk
contentUsage: Record<string, number>
clicdUsed: number
}) {
const { t } = useLanguage()
const total = Math.max(0, disk.size_bytes || 0)
const free = Math.max(0, Math.min(total, disk.free_bytes || 0))
const used = Math.max(0, total - free)
const rawContentSegments = contentOptions.map(([value, label]) => ({
key: value,
label,
size: Math.max(0, contentUsage[value] || 0),
color: contentColors[value],
}))
const rawContentTotal = rawContentSegments.reduce((sum, segment) => sum + segment.size, 0)
const normalizedClicdUsed = Math.max(0, Math.min(used, Math.max(clicdUsed || 0, rawContentTotal)))
const contentScale = rawContentTotal > normalizedClicdUsed && rawContentTotal > 0
? normalizedClicdUsed / rawContentTotal
: 1
const contentSegments = rawContentSegments.map((segment) => ({ ...segment, size: segment.size * contentScale }))
const categorizedClicdUsed = contentSegments.reduce((sum, segment) => sum + segment.size, 0)
const unclassifiedClicdUsed = Math.max(0, normalizedClicdUsed - categorizedClicdUsed)
const nonClicdUsed = Math.max(0, used - normalizedClicdUsed)
const segments = [
...contentSegments,
{ key: 'clicd-other', label: 'CLICD 其他', size: unclassifiedClicdUsed, color: '#111827' },
{ key: 'other', label: '非 CLICD', size: nonClicdUsed, color: '#4b5563' },
{ key: 'free', label: '可用空间', size: free, color: '#e5e7eb' },
].filter((segment) => segment.size > 0)
return (
<div className="min-w-[420px] max-w-[620px]">
<div className="flex items-center justify-between gap-4 text-xs text-gray-600">
<span>{t('已用')} {formatBytes(used)} / {formatBytes(total)}</span>
<span>{usagePct(used, total).toFixed(1)}% · {t('可用')} {formatBytes(free)}</span>
</div>
<div className="mt-2 flex h-8 w-full overflow-hidden rounded-md border border-gray-300 bg-gray-100">
{segments.map((segment) => {
const pct = usagePct(segment.size, total)
return (
<div
key={segment.key}
title={`${t(segment.label)}: ${formatBytes(segment.size)} (${pct.toFixed(2)}%)`}
className="flex h-full items-center justify-center overflow-hidden border-r border-white/70 text-[10px] font-medium text-white last:border-r-0"
style={{ width: `${pct}%`, minWidth: pct > 0 && pct < 0.6 ? '3px' : undefined, backgroundColor: segment.color }}
>
{pct >= 9 && <span className={segment.key === 'free' ? 'text-gray-600' : ''}>{t(segment.label)}</span>}
</div>
)
})}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1.5">
{segments.map((segment) => (
<div key={segment.key} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm border border-black/5" style={{ backgroundColor: segment.color }} />
<span>{t(segment.label)}</span>
<span className="font-medium text-gray-800">{formatBytes(segment.size)}</span>
<span className="text-gray-400">{usagePct(segment.size, total).toFixed(1)}%</span>
</div>
))}
</div>
</div>
)
}
function poolForDisk(pool: StoragePool, disk: StorageDisk) {
if (!disk.mount_point) return false
const mount = cleanPath(disk.mount_point)
const poolMount = cleanPath(pool.mount_point || '')
const poolPath = cleanPath(pool.path || '')
return poolMount === mount || poolPath === mount || poolPath.startsWith(`${mount}/`)
}
function defaultPoolForDisk(disk: StorageDisk): StoragePool {
const mount = cleanPath(disk.mount_point || '/')
const baseName = mount === '/' ? 'system' : mount.split('/').filter(Boolean).pop() || disk.name || 'disk'
const primaryContents = mount === '/' ? contentOptions.map(([value]) => value) : []
return {
id: `disk-${slugID(mount === '/' ? 'root' : baseName)}`,
name: `${baseName} (${disk.path || disk.name})`,
path: mount === '/' ? '/var/lib/clicd' : `${mount}/clicd`,
content_types: primaryContents,
default_contents: [...primaryContents],
enabled: true,
mount_point: disk.mount_point,
}
}
function cleanPath(value: string) {
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || '/'
}
function slugID(value: string) {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'storage'
}
function contentUsageMap(items: Array<{ content_type: string; size_bytes: number }>) {
return items.reduce<Record<string, number>>((acc, item) => {
acc[item.content_type] = (acc[item.content_type] || 0) + (item.size_bytes || 0)
return acc
}, {})
}
function usagePct(used: number, total: number) {
if (!total || total <= 0) return 0
return Math.max(0, Math.min(100, (used / total) * 100))
}
function formatBytes(bytes: number) {
if (!bytes) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index++
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
+133 -4
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
import { useDialog } from '../components/Dialog'
import api, { AuditLog, LoginLog } from '../services/api'
import { useLanguage } from '../contexts/LanguageContext'
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface SubUserItem {
@@ -9,6 +10,9 @@ interface SubUserItem {
username: string
container_names: string[]
container_uuids: string[]
allowed_image_ids?: string[]
image_limit_configured?: boolean
current_image_ids?: string[]
container_name: string
container_uuid: string
access_code: string
@@ -28,12 +32,18 @@ interface AuditLogExt extends AuditLog {
export default function SubUserManagement() {
const dialog = useDialog()
const { t } = useLanguage()
const [users, setUsers] = useState<SubUserItem[]>([])
const [loading, setLoading] = useState(true)
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
const [modalTitle, setModalTitle] = useState('')
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
const [imageUser, setImageUser] = useState<SubUserItem | null>(null)
const [images, setImages] = useState<ImageInfo[]>([])
const [selectedImageIDs, setSelectedImageIDs] = useState<string[]>([])
const [imagesLoading, setImagesLoading] = useState(false)
const [savingImages, setSavingImages] = useState(false)
const [rotatingPassword, setRotatingPassword] = useState(false)
const [logPage, setLogPage] = useState(1)
const [logPageSize, setLogPageSize] = useState(10)
@@ -78,6 +88,46 @@ export default function SubUserManagement() {
}
}
const openImageLimit = async (user: SubUserItem) => {
setImageUser(user)
setSelectedImageIDs(user.allowed_image_ids || [])
setImagesLoading(true)
try {
const res = await getImages()
const currentIDs = new Set(user.current_image_ids || [])
setImages((res.data.data || []).filter((image) => image.downloaded && (image.enabled || currentIDs.has(image.id))))
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('加载失败', error.response?.data?.message || '获取镜像列表失败')
} finally {
setImagesLoading(false)
}
}
const toggleImageID = (id: string) => {
setSelectedImageIDs((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])
}
const saveImageLimit = async () => {
if (!imageUser) return
setSavingImages(true)
try {
const res = await updateSubUserImages(imageUser.id, selectedImageIDs)
const updated = {
...imageUser,
allowed_image_ids: res.data.data?.allowed_image_ids || selectedImageIDs,
image_limit_configured: true,
}
setUsers((prev) => prev.map((item) => (item.id === imageUser.id ? { ...item, allowed_image_ids: updated.allowed_image_ids, image_limit_configured: true } : item)))
setImageUser(null)
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('保存失败', error.response?.data?.message || '保存可用镜像失败')
} finally {
setSavingImages(false)
}
}
const showAuditLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
@@ -125,8 +175,10 @@ export default function SubUserManagement() {
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-semibold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> {users.length} </p>
<h1 className="text-xl font-semibold text-black dark:text-white">{t('子用户管理')}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('容器分配的子用户列表,共')} {users.length} {t('个')}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
@@ -190,6 +242,14 @@ export default function SubUserManagement() {
<LogIn className="w-3.5 h-3.5" />
</button>
<button
onClick={() => openImageLimit(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors"
title="可用镜像"
>
<HardDrive className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
@@ -253,6 +313,75 @@ export default function SubUserManagement() {
</div>
)}
{imageUser && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<div>
<h3 className="text-sm font-semibold text-black dark:text-white"></h3>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{imageUser.username} · </p>
</div>
<button onClick={() => setImageUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
{imagesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-7 w-7 animate-spin rounded-full border-b-2 border-black" />
</div>
) : images.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-10 text-center text-sm text-gray-500">
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{images.map((image) => {
const checked = selectedImageIDs.includes(image.id)
const current = (imageUser.current_image_ids || []).includes(image.id)
return (
<label
key={image.id}
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-3 text-sm transition-colors ${checked ? 'border-black bg-gray-50 dark:border-white dark:bg-gray-800' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleImageID(image.id)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-black dark:text-white">{image.name}{current ? '(当前系统)' : ''}</span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
{image.type.toUpperCase()} · {image.arch} · {image.distro} {image.release}
</span>
</span>
</label>
)
})}
</div>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
<span className="text-xs text-gray-500 dark:text-gray-400"> {selectedImageIDs.length} </span>
<div className="flex items-center gap-2">
<button onClick={() => setImageUser(null)} className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 rounded-md">
</button>
<button
onClick={saveImageLimit}
disabled={savingImages || imagesLoading}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
>
<Save className="h-4 w-4" />
{savingImages ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Log Modal */}
{(auditLogs || loginLogs) && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
+153 -4
View File
@@ -75,6 +75,8 @@ export interface Container {
uuid: string
name: string
virtualization?: string
storage_pool_id?: string
storage_path?: string
template: string
vcpu: number
ram_mb: number
@@ -94,6 +96,12 @@ export interface Container {
io_write_mbps: number
status: string
ip: string
lan_ipv4_mode?: string
lan_interface?: string
lan_ipv4_address?: string
lan_ipv4_prefix_len?: number
lan_ipv4_gateway?: string
mac_address?: string
public_ipv4s?: PublicIPv4Assignment[]
ipv6: string
ipv6_prefix_len: number
@@ -137,6 +145,7 @@ export interface CreateContainerRequest {
name: string
virtualization: string
template_id: string
storage_pool_id?: string
vcpu: number
cpu_percent: number
ram_mb: number
@@ -154,6 +163,11 @@ export interface CreateContainerRequest {
extra_ports: number[]
port_mapping_count: number
assign_nat?: boolean
lan_ipv4_mode?: string
lan_interface?: string
lan_ipv4_address?: string
lan_ipv4_prefix_len?: number
lan_ipv4_gateway?: string
snapshot_limit: number
assign_ipv4?: boolean
ipv4_count?: number
@@ -164,9 +178,56 @@ export interface CreateContainerRequest {
ssh_auth_mode?: string
ssh_password?: string
ssh_public_key?: string
allowed_image_ids?: string[]
image_limit_configured?: boolean
expires_at: string
}
export interface StoragePool {
id: string
name: string
path: string
content_types: string[]
default_contents?: string[]
enabled: boolean
available?: boolean
exists?: boolean
size_bytes?: number
used_bytes?: number
free_bytes?: number
mount_point?: string
clicd_used_bytes?: number
content_usage?: StorageContentUsage[]
error?: string
}
export interface StorageContentUsage {
content_type: string
size_bytes: number
}
export interface StorageDisk {
name: string
path: string
type: string
fstype: string
mount_point: string
model: string
size_bytes: number
used_bytes: number
free_bytes: number
storage_pool_id?: string
storage_path?: string
clicd_used_bytes?: number
content_usage?: StorageContentUsage[]
}
export interface StorageInfo {
pools: StoragePool[]
disks: StorageDisk[]
content_types: string[]
}
export interface ReinstallContainerOptions {
ssh_auth_mode?: string
ssh_password?: string
@@ -245,6 +306,23 @@ export interface HostInfo {
}
}
export interface CreateSnapshotOptions {
storage_pool_id?: string
}
export interface HostMetricPoint {
ts: number
cpu: number
memory: number
network: number
network_rx: number
network_tx: number
disk_io: number
disk_read: number
disk_write: number
disk_usage_pct: number
}
export interface HostProbeReport {
generated_at: string
hostname: string
@@ -353,6 +431,18 @@ export interface ContainerUsage {
guest_metrics?: boolean
}
export interface ContainerMetricPoint {
ts: number
cpu: number
memory: number
network: number
network_rx: number
network_tx: number
disk_io: number
disk_read: number
disk_write: number
}
export interface APIResponse<T = unknown> {
success: boolean
message?: string
@@ -392,6 +482,18 @@ export interface AuditLog {
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
export interface TaskQueueSettings {
concurrency: number
active: number
pending: number
}
export const getTaskQueueSettings = () =>
api.get<APIResponse<TaskQueueSettings>>('/task-queue/settings')
export const updateTaskQueueSettings = (concurrency: number) =>
api.put<APIResponse<TaskQueueSettings>>('/task-queue/settings', { concurrency })
export interface SSLCertificateInfo {
subject: string
issuer: string
@@ -475,6 +577,9 @@ export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
export const getContainerHistory = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerMetricPoint[]>>(`/containers/${id}/history`)
export interface TrafficInfo {
total_used_bytes: number
rx_used_bytes: number
@@ -537,6 +642,18 @@ export const getIPv6Status = () =>
export const assignIPv6 = (id: ContainerIdentifier) =>
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
export interface IPAssignmentUpdateRequest {
mode: 'clear' | 'random' | 'custom'
count?: number
addresses?: string[]
}
export const updatePublicIPv4Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
api.put<APIResponse<Container>>(`/containers/${id}/public-ipv4`, data)
export const updateIPv6Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
api.put<APIResponse<Container>>(`/containers/${id}/ipv6-addresses`, data)
export interface RouteCapacity {
used: number
remaining: string
@@ -572,6 +689,19 @@ export interface IPv4Route {
gateway?: string
}
export interface LANDHCPRoute {
container_id: number
container_name: string
lxc_name: string
status: string
address: string
interface: string
prefix_len?: number
gateway?: string
mac_address?: string
mode: string
}
export interface IPv6Route {
container_id: number
container_name: string
@@ -586,10 +716,12 @@ export interface RoutingInfo {
nat4: RouteCapacity
nat4_port_range: NAT4PortRange
ipv4: RouteCapacity
lan_dhcp: RouteCapacity
ipv6: RouteCapacity
host_public_ipv4?: PublicIPv4Info
public_ipv4_addresses: PublicIPv4Info[]
ipv4_assignments: IPv4Route[]
lan_dhcp_assignments: LANDHCPRoute[]
nat4_mappings: NAT4Route[]
ipv6_assignments: IPv6Route[]
ipv6_prefixes: IPv6PrefixInfo[]
@@ -657,8 +789,8 @@ export const deleteImage = (templateId: string) =>
export const toggleImage = (templateId: string, enabled: boolean) =>
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
export const getEnabledImages = (virtualization = 'lxc') =>
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
export const getEnabledImages = (virtualization = 'lxc', container?: ContainerIdentifier) =>
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization, ...(container ? { container: String(container) } : {}) } })
// Dashboard
export const getDashboard = () =>
@@ -667,9 +799,18 @@ export const getDashboard = () =>
export const getHostInfo = () =>
api.get<APIResponse<HostInfo>>('/host-info')
export const getHostHistory = () =>
api.get<APIResponse<HostMetricPoint[]>>('/host-history')
export const getHostReport = () =>
api.get<APIResponse<HostProbeReport>>('/host-report')
export const getStorageInfo = () =>
api.get<APIResponse<StorageInfo>>('/storage')
export const updateStoragePools = (pools: StoragePool[]) =>
api.put<APIResponse<StorageInfo>>('/storage', { pools })
// Snapshots
export interface Snapshot {
id: string
@@ -704,8 +845,8 @@ export const getSnapshots = () =>
export const getContainerSnapshots = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
export const createContainerSnapshot = (id: ContainerIdentifier) =>
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
export const createContainerSnapshot = (id: ContainerIdentifier, options?: CreateSnapshotOptions) =>
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, options || {}, { timeout: 600000 })
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
@@ -747,6 +888,8 @@ export interface Task {
container_name: string
status: string
error?: string
stage?: string
stage_detail?: string
created_at: string
template_id?: string
config?: CreateContainerRequest
@@ -771,6 +914,9 @@ export interface SubUser {
password?: string
container_names: string[]
container_uuids?: string[]
allowed_image_ids?: string[]
image_limit_configured?: boolean
current_image_ids?: string[]
access_code: string
created_at: string
}
@@ -778,6 +924,9 @@ export interface SubUser {
export const createSubUser = (containerId: ContainerIdentifier) =>
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
export const updateSubUserImages = (id: string, allowedImageIds: string[]) =>
api.put<APIResponse<SubUser>>(`/sub-users/${id}/images`, { allowed_image_ids: allowedImageIds })
// Audit Logs
export interface AuditLog {
time: string
+177
View File
@@ -392,6 +392,23 @@ const exact: Record<string, string> = {
'暂未获取到宿主机信息': 'No host information available',
'面板资源状态与容器概览': 'Panel resource status and container overview',
'宿主机资源状态与容器概览': 'Host resource status and container overview',
'存储管理': 'Storage Management',
'只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。': 'Only mounted disks are shown. Enable a content type to make that disk available to the corresponding feature.',
'空间分布': 'Space Distribution',
'用于存储': 'Storage Usage',
'未检测到已挂载磁盘': 'No mounted disks detected',
'镜像缓存': 'Image Cache',
'备份': 'Backups',
'默认盘': 'Default Disk',
'设为': 'Set as',
'CLICD 其他': 'Other CLICD Data',
'非 CLICD': 'Non-CLICD Data',
'可用空间': 'Free Space',
'存储配置已保存': 'Storage settings saved',
'保存存储配置失败': 'Failed to save storage settings',
'任务队列、账号、安全证书与访问记录': 'Task queue, account, certificates, and access records',
'设置分类': 'Settings categories',
'WebSSH 访问': 'WebSSH Access',
'账号设置': 'Account Settings',
'当前用户名': 'Current Username',
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
@@ -401,6 +418,12 @@ const exact: Record<string, string> = {
'至少 6 位': 'At least 6 characters',
'输入当前密码以确认修改': 'Enter current password to confirm changes',
'保存修改': 'Save Changes',
'总并发上限': 'Total Concurrency Limit',
'减少并发': 'Decrease concurrency',
'增加并发': 'Increase concurrency',
'保存队列设置': 'Save Queue Settings',
'任务队列并发设置已保存并立即生效': 'Task queue concurrency saved and applied immediately',
'任务队列设置保存失败': 'Failed to save task queue settings',
'SSL 证书': 'SSL Certificate',
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
'IP / 域名': 'IP / Domain',
@@ -761,6 +784,32 @@ const exact: Record<string, string> = {
'初始化失败': 'Initialization failed',
'初始化完成': 'Initialization complete',
'排队等待': 'Queued',
'当前阶段': 'Current Stage',
'准备初始化环境': 'Preparing initialization environment',
'检查模板与创建参数': 'Checking template and creation settings',
'下载模板并创建基础文件系统': 'Downloading template and creating root filesystem',
'复制容器数据到存储磁盘': 'Copying container data to storage disk',
'创建容量限制磁盘并复制 rootfs': 'Creating quota disk and copying rootfs',
'配置 CPU、内存与网络限制': 'Configuring CPU, memory, and network limits',
'分配 IPv4、IPv6 与 NAT 端口': 'Allocating IPv4, IPv6, and NAT ports',
'保存容器配置': 'Saving container configuration',
'写入容器网络配置': 'Writing container network configuration',
'安装并配置 SSH 服务': 'Installing and configuring SSH',
'检测并预配置 SSH 服务': 'Detecting and preconfiguring SSH',
'转换非特权容器文件权限': 'Converting unprivileged container permissions',
'设置容器登录凭据': 'Setting container login credentials',
'启动容器并等待网络就绪': 'Starting container and waiting for network',
'启动虚拟机并等待网络就绪': 'Starting VM and waiting for network',
'检查 KVM 镜像与创建参数': 'Checking KVM image and creation settings',
'选择虚拟机存储磁盘': 'Selecting VM storage disk',
'分配 IPv4 与 IPv6 地址': 'Allocating IPv4 and IPv6 addresses',
'创建 Windows 虚拟磁盘': 'Creating Windows virtual disk',
'生成 Windows 自动应答配置': 'Generating Windows unattended setup',
'创建 KVM 系统磁盘': 'Creating KVM system disk',
'生成 cloud-init 初始化配置': 'Generating cloud-init configuration',
'注册 KVM 虚拟机': 'Registering KVM virtual machine',
'分配并配置 NAT 端口': 'Allocating and configuring NAT ports',
'保存虚拟机配置': 'Saving virtual machine configuration',
'处理中': 'Processing',
'未知系统': 'Unknown system',
'处理失败': 'Failed',
@@ -886,6 +935,132 @@ const exact: Record<string, string> = {
'生成新密码': 'Generate new password',
'自定义密码': 'Custom password',
'生成密码': 'Generate password',
'不限速': 'Unlimited',
'下': 'Down',
'不限': 'Unlimited',
'/ 上': '/ Up',
'请选择登录方式': 'Select a login method',
'未检测到可分配公网 IPv4': 'No allocatable public IPv4 detected',
'使用': 'Use',
'正在检测 IPv6 前缀...': 'Checking IPv6 prefixes...',
'公网 NAT': 'Public NAT',
'不分配 NAT 端口': 'Do not assign NAT ports',
'未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。': 'No allocatable IPv6 prefix was detected. The host only has a single /128 IPv6 address, which cannot be assigned to containers.',
'宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。': 'The host detected an IPv6 prefix, but the outbound IPv6 connectivity test failed.',
'个可分配地址': 'allocatable addresses',
'将分配': 'Will assign',
'请勾选任意一个可用网络': 'Select at least one available network',
'局域网 IPv4 配置有误': 'Invalid LAN IPv4 configuration',
'请填写有效的 IPv4 地址、子网掩码和网关': 'Enter a valid IPv4 address, subnet mask, and gateway',
'未配置存储': 'Storage not configured',
'请先在存储管理中为': 'In Storage Management, enable storage for',
'开启至少一块存储磁盘': 'Enable at least one storage disk',
'登录方式有误': 'Invalid login method',
'至': 'to',
'当前宿主机不支持 KVM': 'The current host does not support KVM',
'系统镜像,请先在「镜像管理」中下载镜像模板。': 'system images available. Download an image template from Images first.',
'存储磁盘': 'Storage Disk',
'自动选择(默认盘优先,空间不足自动切换)': 'Automatic selection (prefer default disk and switch when space is insufficient)',
'尚未开启': 'Not enabled',
'存储,当前无法创建。': 'storage is not enabled, so creation is currently unavailable.',
'去开启': 'Configure Now',
'默认勾选当前系统;取消后,子用户也不能重装该系统。': 'The current system is selected by default. Clearing it also prevents sub-users from reinstalling that system.',
'局域网 DHCP': 'LAN DHCP',
'macvlan 独立局域网 IP': 'Independent LAN IP via macvlan',
'未检测到可用上联网卡': 'No available uplink interface detected',
'DHCP 自动获取': 'Obtain automatically via DHCP',
'子网掩码': 'Subnet Mask',
'不选则长期有效': 'Leave blank for no expiration',
'均': 'Avg',
'/ 峰': '/ Peak',
'到期': 'Expires',
'未分配': 'Unassigned',
'下行': 'Download',
'上行': 'Upload',
'修改公网 IP 分配': 'Change Public IP Assignment',
'尚未开启快照存储,无法新建或启用定时快照。': 'Snapshot storage is not enabled. New and scheduled snapshots are unavailable.',
'新建快照存储磁盘': 'Storage Disk for New Snapshots',
'仅影响手动新建快照;定时快照使用默认磁盘。': 'Only affects manually created snapshots. Scheduled snapshots use the default disk.',
'在': 'at',
'IPv4 规则覆盖': 'IPv4 rules cover',
'独立公网 IPv4': 'independent public IPv4',
'公网 IP 分配': 'Public IP Assignment',
'修改后会重放端口映射、SNAT 和防火墙规则。': 'Changing assignments reapplies port mappings, SNAT, and firewall rules.',
'随机数量': 'Random Count',
'没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。': 'No public IPv4 addresses are available. Configure the IPv4 pool in Routing first.',
'独立 IPv6': 'Independent IPv6',
'自定义地址必须落在路由管理配置的 IPv6 前缀内。': 'Custom addresses must be within an IPv6 prefix configured in Routing.',
'未分配 IPv4 NAT 端口配额': 'No IPv4 NAT port quota assigned',
'已达到管理员分配的 IPv4 NAT 端口配额': 'The administrator-assigned IPv4 NAT port quota has been reached',
'不分配': 'Do Not Assign',
'随机分配': 'Random Allocation',
'自定义': 'Custom',
'SSH Key 格式不正确': 'Invalid SSH key format',
'公网 IP 分配失败': 'Public IP assignment failed',
'请检查地址是否可用或已被占用': 'Check whether the address is available or already in use',
'未分配 IPv4 NAT': 'IPv4 NAT not assigned',
'该容器未分配 IPv4 NAT 端口配额。': 'This container has no IPv4 NAT port quota.',
'未配置快照存储': 'Snapshot storage not configured',
'请先在存储管理中为快照开启至少一块存储磁盘。': 'Enable at least one snapshot storage disk in Storage Management first.',
'个月': 'months',
'个任务': 'tasks',
'剩余': 'Remaining',
'磨损': 'Wear',
'启停': 'Power Cycles',
'线程': 'threads',
'块硬盘': 'disks',
'个进程': 'processes',
'虚拟': 'Virtual',
'尚未开启镜像缓存存储,无法下载新镜像。': 'Image cache storage is not enabled, so new images cannot be downloaded.',
'请先在存储管理中开启镜像缓存存储': 'Enable image cache storage in Storage Management first',
'正在检查存储配置...': 'Checking storage configuration...',
'池内': 'In Pool',
'范围': 'Range',
'条映射': 'mappings',
'模式': 'Mode',
'NAT4、公网 IPv4 池和 IPv6 地址分配': 'NAT4, public IPv4 pool, and IPv6 address assignment',
'编辑 NAT4 范围': 'Edit NAT4 Range',
'起始端口': 'Start Port',
'结束端口': 'End Port',
'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口': 'The NAT4 range must be within 1-65535, and the start port cannot exceed the end port',
'保存 NAT4 范围失败': 'Failed to save NAT4 range',
'剩余 / 总数': 'Remaining / Total',
'由局域网 DHCP 分配': 'Assigned by LAN DHCP',
'局域网 DHCP 分配': 'LAN DHCP Assignments',
'暂无局域网 DHCP 分配': 'No LAN DHCP assignments',
'公网 IPv4 池': 'Public IPv4 Pool',
'编辑 IP 池': 'Edit IP Pool',
'暂未配置公网 IPv4 池': 'No public IPv4 pool configured',
'掩码': 'Mask',
'分配给': 'Assigned To',
'空闲': 'Free',
'编辑 IPv4 池': 'Edit IPv4 Pool',
'IPv4 网关不能为空': 'IPv4 gateway is required',
'IPv4 地址不能为空': 'IPv4 address is required',
'保存 IPv4 池失败': 'Failed to save IPv4 pool',
'打开容器': 'Open Container',
'IPv4 池内暂无地址': 'No addresses in the IPv4 pool',
'添加 IPv4': 'Add IPv4',
'检测到的 IPv6 前缀': 'Detected IPv6 Prefixes',
'暂无 IPv6 前缀': 'No IPv6 prefixes',
'IPv6 网卡不能为空': 'IPv6 interface is required',
'本机': 'Local',
'暂无 IPv4 NAT 映射': 'No IPv4 NAT mappings',
'运行时名称': 'Runtime Name',
'客户机 IPv4': 'Guest IPv4',
'宿主 IPv4': 'Host IPv4',
'宿主端口': 'Host Port',
'客户机端口': 'Guest Port',
'大量': 'Large',
'的快照吗?此操作不可恢复。': ' snapshot? This action cannot be undone.',
'· 默认勾选当前系统,取消后将禁止重装该系统': ' · the current system is selected by default; clearing it prevents reinstalling that system',
'暂无已下载并启用的镜像': 'No downloaded and enabled images',
'已选择': 'Selected',
'加载失败': 'Loading failed',
'请填写 SSH 公钥': 'Enter an SSH public key',
'SSH 公钥长度不能超过 8192 字符': 'The SSH public key cannot exceed 8192 characters',
'SSH 公钥只能填写一行': 'The SSH public key must be on one line',
'SSH 公钥格式不正确': 'Invalid SSH public key format',
}
const artifactPatterns: RegExp[] = [
@@ -939,6 +1114,7 @@ const replacements: Array<[RegExp, string]> = [
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
[/共\s*(\d+)\s*个\s*任务/g, 'Total $1 tasks'],
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
[/共\s*(\d+)\s*条/g, 'Total $1'],
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
@@ -988,6 +1164,7 @@ const replacements: Array<[RegExp, string]> = [
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
[/阶段:(.+)$/g, 'Stage: $1'],
[/正在初始化:(.+)$/g, 'Initializing: $1'],
[/\$\{days\}天/g, '${days} days'],
[/\$\{hours\}小时/g, '${hours} hours'],
[/\$\{hours\}\s*小时/g, '${hours} hours'],
+12 -2
View File
@@ -537,6 +537,7 @@ remove_clicd_lxc_image_cache() {
for image in \
"ubuntu noble amd64" \
"ubuntu jammy amd64" \
"debian trixie amd64" \
"debian bookworm amd64" \
"debian bullseye amd64" \
"alpine 3.21 amd64" \
@@ -546,6 +547,7 @@ remove_clicd_lxc_image_cache() {
"rockylinux 10 amd64" \
"ubuntu noble arm64" \
"ubuntu jammy arm64" \
"debian trixie arm64" \
"debian bookworm arm64" \
"debian bullseye arm64" \
"alpine 3.21 arm64" \
@@ -1324,7 +1326,9 @@ setup_runtime_services() {
libvirt_network_active() {
virsh net-info default 2>/dev/null | awk -F: 'tolower($1) ~ /^[[:space:]]*active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' | grep -qx yes
LC_ALL=C LANG=C virsh net-info default 2>/dev/null \
| awk -F: '$1 ~ /^[[:space:]]*Active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' \
| grep -qx yes
}
setup_default_libvirt_network() {
@@ -1353,7 +1357,13 @@ EOF
touch "$LIBVIRT_DEFAULT_MARKER"
fi
if ! libvirt_network_active; then
virsh net-start default
if ! start_output="$(LC_ALL=C LANG=C virsh net-start default 2>&1)"; then
# Another process may have activated the network after our check.
if ! libvirt_network_active; then
printf '%s\n' "$start_output" >&2
die "libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。"
fi
fi
fi
virsh net-autostart default >/dev/null
if ! libvirt_network_active; then