Compare commits

...

12 Commits

Author SHA1 Message Date
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
MengMengCode 58d86b5d08 release: v1.1.23 2026-07-16 18:57:49 +08:00
MengMengCode 7307255130 FIX #28 2026-07-16 18:57:15 +08:00
MengMengCode 0e3c059236 ci: fix release workflow checkout 2026-07-16 16:58:19 +08:00
MengMengCode 61137b837d release: v1.1.22 2026-07-16 16:58:19 +08:00
Meng Meng 596bf86477 Merge pull request #27 from MengMengCode/dependabot/go_modules/backend/golang.org/x/crypto-0.52.0
build(deps): bump golang.org/x/crypto from 0.45.0 to 0.52.0 in /backend
2026-07-16 15:15:39 +08:00
dependabot[bot] 9eb7c322cf build(deps): bump golang.org/x/crypto from 0.45.0 to 0.52.0 in /backend
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.45.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 11:56:11 +00:00
50 changed files with 3078 additions and 484 deletions
+42 -10
View File
@@ -14,9 +14,15 @@ permissions:
contents: write contents: write
jobs: jobs:
linux-amd64: linux:
name: Linux amd64 name: Linux ${{ matrix.goarch }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goarch:
- amd64
- arm64
steps: steps:
- name: Checkout - name: Checkout
@@ -55,16 +61,21 @@ jobs:
fi fi
- name: Build CLICD - name: Build CLICD
env:
CLICD_GOARCH: ${{ matrix.goarch }}
run: bash build.sh run: bash build.sh
- name: Package CLICD - name: Package CLICD
env:
CLICD_GOARCH: ${{ matrix.goarch }}
run: | run: |
mkdir -p dist package/clicd-linux-amd64 asset_dir="clicd-linux-${CLICD_GOARCH}"
cp build/clicd package/clicd-linux-amd64/clicd mkdir -p "dist" "package/${asset_dir}"
cp build/install.sh package/clicd-linux-amd64/install.sh cp build/clicd "package/${asset_dir}/clicd"
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh cp build/install.sh "package/${asset_dir}/install.sh"
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64 chmod +x "package/${asset_dir}/clicd" "package/${asset_dir}/install.sh"
cp build/clicd dist/clicd-linux-amd64 tar -C package -czf "dist/${asset_dir}.tar.gz" "${asset_dir}"
cp build/clicd "dist/${asset_dir}"
- name: Package Mofang module - name: Package Mofang module
run: | run: |
@@ -87,13 +98,34 @@ jobs:
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: clicd-linux-amd64 name: clicd-linux-${{ matrix.goarch }}
path: dist/* path: dist/*
- name: Publish GitHub Release release:
name: Publish GitHub Release
needs: linux
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist-artifacts
- name: Prepare release assets
run: |
mkdir -p dist
find dist-artifacts -maxdepth 2 -type f ! -name SHA256SUMS -print -exec cp -f {} dist/ \;
sha256sum dist/* > dist/SHA256SUMS
- name: Publish GitHub Release
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: | run: |
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \ gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
gh release upload "$GITHUB_REF_NAME" dist/* --clobber gh release upload "$GITHUB_REF_NAME" dist/* --clobber
+3
View File
@@ -13,6 +13,7 @@ backend/internal/server/web/*
# Build artifacts # Build artifacts
/build/ /build/
/dist/
Mofang/*.zip Mofang/*.zip
*.exe *.exe
*.dll *.dll
@@ -69,3 +70,5 @@ push-release.ps1
deploy.ps1 deploy.ps1
backend/clicd backend/clicd
api.md api.md
deploy-arm.ps1
deploy-dhcp.ps1
+4 -6
View File
@@ -1,18 +1,16 @@
module clicd module clicd
go 1.24.0 go 1.25.0
toolchain go1.24.5
require ( require (
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.52.0
golang.org/x/term v0.37.0 golang.org/x/term v0.43.0
) )
require ( require (
golang.org/x/sys v0.38.0 golang.org/x/sys v0.45.0
modernc.org/sqlite v1.29.10 modernc.org/sqlite v1.29.10
) )
+6 -6
View File
@@ -18,8 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
@@ -27,10 +27,10 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
+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
}
+25 -3
View File
@@ -132,6 +132,11 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
return return
} }
getUsage(w, r, id) 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: case action == "traffic" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") { if !requireScope(w, r, "container:read") {
return return
@@ -240,6 +245,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return 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 { if cfg.VCPU <= 0 {
cfg.VCPU = 1 cfg.VCPU = 1
} }
@@ -452,13 +463,12 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
config.NormalizeContainerResourceAliases(c) config.NormalizeContainerResourceAliases(c)
config.SaveConfig() config.SaveConfig()
// Re-apply resource limits to running container // Re-apply persisted/runtime limits. LXC also uses this path to migrate
if c.Status == "running" { // old managed config lines such as lxc.prlimit.nproc.
if err := applyLimitsByRuntime(c); err != nil { if err := applyLimitsByRuntime(c); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return return
} }
}
msg := "Resource limits updated" msg := "Resource limits updated"
if c.IsKVM() && c.Status == "running" { if c.IsKVM() && c.Status == "running" {
@@ -656,6 +666,18 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) 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) { func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c != nil && lxc.IsExpired(*c) { if c != nil && lxc.IsExpired(*c) {
+482 -19
View File
@@ -5,6 +5,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"math"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -27,6 +29,7 @@ type HostInfo struct {
Network NetworkInfo `json:"network"` Network NetworkInfo `json:"network"`
DiskIO DiskIOInfo `json:"disk_io"` DiskIO DiskIOInfo `json:"disk_io"`
Load LoadInfo `json:"load"` Load LoadInfo `json:"load"`
Runtime HostRuntimeProbe `json:"runtime"`
} }
type HostProbeReport struct { type HostProbeReport struct {
@@ -215,10 +218,34 @@ type DiskIOInfo struct {
WriteBps float64 `json:"write_bps"` 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 hostCPUMu sync.Mutex
var lastHostCPU cpuTimes var lastHostCPU cpuTimes
var hostIOMu sync.Mutex var hostIOMu sync.Mutex
var lastHostIO hostIOSample 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 { type cpuTimes struct {
Total uint64 Total uint64
@@ -234,6 +261,10 @@ type hostIOSample struct {
} }
func getHostInfo() HostInfo { func getHostInfo() HostInfo {
return getHostInfoWithNetworkDetails(true)
}
func getHostInfoWithNetworkDetails(includeDetails bool) HostInfo {
info := HostInfo{ info := HostInfo{
CPU: CpuInfo{Cores: runtime.NumCPU()}, CPU: CpuInfo{Cores: runtime.NumCPU()},
} }
@@ -241,11 +272,107 @@ func getHostInfo() HostInfo {
info.RAM = getMemoryInfo() info.RAM = getMemoryInfo()
info.Disk = getDiskInfo() info.Disk = getDiskInfo()
info.CPU.Usage = getCPUUsage() info.CPU.Usage = getCPUUsage()
info.Network, info.DiskIO = getHostRates() info.Network, info.DiskIO = getHostRates(includeDetails)
info.Load = getLoadInfo() info.Load = getLoadInfo()
info.Runtime = detectRuntimeProbeQuick()
return info 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 { func getMemoryInfo() MemoryInfo {
f, err := os.Open("/proc/meminfo") f, err := os.Open("/proc/meminfo")
if err != nil { if err != nil {
@@ -391,13 +518,14 @@ func parseSizeGBf(s string) (float64, error) {
return val, err return val, err
} }
func getHostRates() (NetworkInfo, DiskIOInfo) { func getHostRates(includeDetails bool) (NetworkInfo, DiskIOInfo) {
rx, tx := readHostNetworkBytes() rx, tx := readHostNetworkBytes()
readBytes, writeBytes := readHostDiskBytes() readBytes, writeBytes := readHostDiskBytes()
now := unixNano() now := unixNano()
network := NetworkInfo{RXBytes: rx, TXBytes: tx} network := NetworkInfo{RXBytes: rx, TXBytes: tx}
publicIPv4 := lxc.DetectPublicIPv4() if includeDetails {
publicIPv4 := detectDisplayPublicIPv4()
network.PublicIPv4 = publicIPv4.Address network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface network.PublicIPv4Interface = publicIPv4.Interface
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0) network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
@@ -406,6 +534,7 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
network.PublicIPv6 = network.IPv6Prefixes[0].Address network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
} }
}
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes} diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
hostIOMu.Lock() hostIOMu.Lock()
@@ -437,23 +566,79 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
} }
func readHostNetworkBytes() (uint64, uint64) { func readHostNetworkBytes() (uint64, uint64) {
entries, err := os.ReadDir("/sys/class/net") ifaces := detectHostTrafficInterfaces()
if err != nil { if len(ifaces) == 0 {
return 0, 0 ifaces = fallbackHostTrafficInterfaces()
} }
var rx, tx uint64 var rx, tx uint64
for _, entry := range entries { for _, name := range ifaces {
name := entry.Name()
if name == "lo" {
continue
}
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes") rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes") tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
} }
return rx, tx return rx, tx
} }
func detectHostTrafficInterfaces() []string {
seen := map[string]bool{}
result := make([]string, 0, 2)
add := func(name string) {
name = strings.TrimSpace(name)
if !isHostTrafficInterface(name) || seen[name] {
return
}
seen[name] = true
result = append(result, name)
}
if iface, _ := detectDefaultIPv4Route(); iface != "" {
add(iface)
}
if iface, _ := detectDefaultIPv6Route(); iface != "" {
add(iface)
}
if pub := lxc.DetectPublicIPv4(); pub.Interface != "" {
add(pub.Interface)
}
for _, prefix := range lxc.DetectHostPublicIPv6Prefixes() {
add(prefix.Interface)
}
return result
}
func fallbackHostTrafficInterfaces() []string {
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return nil
}
result := make([]string, 0)
for _, entry := range entries {
name := entry.Name()
if !isHostTrafficInterface(name) {
continue
}
state := strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "operstate")))
if state == "down" {
continue
}
result = append(result, name)
}
sort.Strings(result)
return result
}
func isHostTrafficInterface(name string) bool {
name = strings.TrimSpace(name)
if name == "" || name == "lo" {
return false
}
if isContainerLikeInterfaceName(name) {
return false
}
return true
}
func readHostDiskBytes() (uint64, uint64) { func readHostDiskBytes() (uint64, uint64) {
f, err := os.Open("/proc/diskstats") f, err := os.Open("/proc/diskstats")
if err != nil { if err != nil {
@@ -574,6 +759,8 @@ func trimOSReleaseValue(value string) string {
func detectHostCPUProbe() HostCPUProbe { func detectHostCPUProbe() HostCPUProbe {
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH} probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
armImplementer := ""
armPart := ""
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil { if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
seenFlags := map[string]bool{} seenFlags := map[string]bool{}
for _, line := range strings.Split(string(data), "\n") { for _, line := range strings.Split(string(data), "\n") {
@@ -582,19 +769,28 @@ func detectHostCPUProbe() HostCPUProbe {
continue continue
} }
key := strings.TrimSpace(fields[0]) key := strings.TrimSpace(fields[0])
keyLower := strings.ToLower(key)
value := strings.TrimSpace(fields[1]) value := strings.TrimSpace(fields[1])
switch key { switch keyLower {
case "model name", "Hardware", "Processor": case "model name", "hardware", "processor":
if probe.Model == "" { if probe.Model == "" && meaningfulCPUModel(value) {
probe.Model = value probe.Model = value
} }
case "cpu cores": case "cpu cores":
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores { if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
probe.Cores = cores probe.Cores = cores
} }
case "flags", "Features": case "cpu implementer":
if armImplementer == "" {
armImplementer = strings.ToLower(value)
}
case "cpu part":
if armPart == "" {
armPart = strings.ToLower(value)
}
case "flags", "features":
for _, flag := range strings.Fields(value) { for _, flag := range strings.Fields(value) {
if flag == "vmx" || flag == "svm" { if flag == "vmx" || flag == "svm" || flag == "virt" {
probe.Virtualization = true probe.Virtualization = true
probe.VirtualizationKey = flag probe.VirtualizationKey = flag
} }
@@ -607,12 +803,132 @@ func detectHostCPUProbe() HostCPUProbe {
} }
sort.Strings(probe.Flags) sort.Strings(probe.Flags)
} }
enrichCPUProbeFromLscpu(&probe, &armImplementer, &armPart)
if probe.Model == "" {
probe.Model = armCPUModelName(armImplementer, armPart)
}
if probe.Model == "" && runtime.GOARCH == "arm64" {
probe.Model = "ARM64 CPU"
}
if probe.Model == "" { if probe.Model == "" {
probe.Model = "Unknown" probe.Model = "Unknown"
} }
return probe return probe
} }
func meaningfulCPUModel(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
if _, err := strconv.Atoi(value); err == nil {
return false
}
lower := strings.ToLower(value)
return lower != "unknown" && lower != "not specified"
}
func enrichCPUProbeFromLscpu(probe *HostCPUProbe, armImplementer *string, armPart *string) {
out := runCommandOutput(2*time.Second, "lscpu")
if out == "" {
return
}
for _, line := range strings.Split(out, "\n") {
fields := strings.SplitN(line, ":", 2)
if len(fields) != 2 {
continue
}
key := strings.ToLower(strings.TrimSpace(fields[0]))
value := strings.TrimSpace(fields[1])
switch key {
case "model name":
if probe.Model == "" && meaningfulCPUModel(value) {
probe.Model = value
}
case "cpu(s)":
if threads, err := strconv.Atoi(value); err == nil && threads > probe.Threads {
probe.Threads = threads
}
case "core(s) per socket":
if cores, err := strconv.Atoi(value); err == nil && cores > 0 {
probe.Cores = cores
}
case "socket(s)":
if sockets, err := strconv.Atoi(value); err == nil && sockets > 1 && probe.Cores > 0 {
probe.Cores *= sockets
}
case "virtualization":
lower := strings.ToLower(value)
if value != "" && lower != "none" && lower != "n/a" {
probe.Virtualization = true
probe.VirtualizationKey = value
}
case "flags":
seen := map[string]bool{}
for _, flag := range probe.Flags {
seen[flag] = true
}
for _, flag := range strings.Fields(value) {
if flag == "vmx" || flag == "svm" || flag == "virt" {
probe.Virtualization = true
probe.VirtualizationKey = flag
}
if !seen[flag] {
probe.Flags = append(probe.Flags, flag)
seen[flag] = true
}
}
sort.Strings(probe.Flags)
case "cpu implementer":
if *armImplementer == "" {
*armImplementer = strings.ToLower(value)
}
case "cpu part":
if *armPart == "" {
*armPart = strings.ToLower(value)
}
}
}
}
func armCPUModelName(implementer, part string) string {
implementer = normalizeHexID(implementer)
part = normalizeHexID(part)
if implementer == "" || part == "" {
return ""
}
armParts := map[string]string{
"0x41:0xd03": "ARM Cortex-A53",
"0x41:0xd05": "ARM Cortex-A55",
"0x41:0xd07": "ARM Cortex-A57",
"0x41:0xd08": "ARM Cortex-A72",
"0x41:0xd09": "ARM Cortex-A73",
"0x41:0xd0a": "ARM Cortex-A75",
"0x41:0xd0b": "ARM Cortex-A76",
"0x41:0xd0c": "ARM Neoverse N1",
"0x41:0xd0d": "ARM Cortex-A77",
"0x41:0xd40": "ARM Neoverse V1",
"0x41:0xd41": "ARM Cortex-A78",
"0x41:0xd49": "ARM Neoverse N2",
"0x41:0xd4f": "ARM Neoverse V2",
}
if model := armParts[implementer+":"+part]; model != "" {
return model
}
return strings.ToUpper(strings.TrimPrefix(implementer, "0x")) + " ARM CPU part " + part
}
func normalizeHexID(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
if strings.HasPrefix(value, "0x") {
return value
}
return "0x" + value
}
func detectMemoryModules() []HostMemoryModule { func detectMemoryModules() []HostMemoryModule {
if !commandExists("dmidecode") { if !commandExists("dmidecode") {
return nil return nil
@@ -724,7 +1040,7 @@ func isVirtualBlockDevice(name, model, vendor string) bool {
} }
for _, token := range []string{ for _, token := range []string{
"qemu", "virtio", "virtual", "vmware", "vbox", "xen", "qemu", "virtio", "virtual", "vmware", "vbox", "xen",
"amazon elastic block store", "google persistentdisk", "microsoft", "amazon elastic block store", "google persistentdisk", "microsoft", "blockvolume",
} { } {
if strings.Contains(lower, token) { if strings.Contains(lower, token) {
return true return true
@@ -1153,9 +1469,126 @@ func detectAllPublicIPv4() []string {
result = append(result, value) result = append(result, value)
} }
} }
if egress := detectEgressPublicIPv4(); egress.Address != "" {
if !seen[egress.Address] {
seen[egress.Address] = true
result = append(result, egress.Address)
}
}
return result return result
} }
func detectDisplayPublicIPv4() lxc.PublicIPInfo {
if pub := lxc.DetectPublicIPv4(); pub.Address != "" {
return pub
}
return detectEgressPublicIPv4()
}
func detectEgressPublicIPv4() lxc.PublicIPInfo {
egressIPv4Mu.Lock()
defer egressIPv4Mu.Unlock()
if cachedEgressIPv4.Address != "" && time.Since(cachedEgressIPv4At) < 5*time.Minute {
return cachedEgressIPv4
}
client := &http.Client{Timeout: 1200 * time.Millisecond}
for _, endpoint := range []string{
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
} {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
cancel()
continue
}
resp, err := client.Do(req)
if err != nil {
cancel()
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 128))
_ = resp.Body.Close()
cancel()
if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
continue
}
address := strings.TrimSpace(string(body))
ip := net.ParseIP(address)
if !isPublicIPv4(ip) {
continue
}
iface, gateway := detectDefaultIPv4Route()
cachedEgressIPv4 = lxc.PublicIPInfo{
Address: ip.String(),
Interface: iface,
Prefix: ip.String() + "/32",
PrefixLen: 32,
SubnetMask: "255.255.255.255",
Gateway: gateway,
IsTunnel: isTunnelLikeInterfaceName(iface),
Source: "egress",
}
cachedEgressIPv4At = time.Now()
return cachedEgressIPv4
}
cachedEgressIPv4 = lxc.PublicIPInfo{}
cachedEgressIPv4At = time.Now()
return cachedEgressIPv4
}
func detectDefaultIPv4Route() (string, string) {
out := runCommandOutput(2*time.Second, "ip", "-4", "route", "show", "default")
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
iface := ""
gateway := ""
for i, field := range fields {
if field == "dev" && i+1 < len(fields) {
iface = fields[i+1]
}
if field == "via" && i+1 < len(fields) {
gateway = fields[i+1]
}
}
if iface != "" || gateway != "" {
return iface, gateway
}
}
return "", ""
}
func detectDefaultIPv6Route() (string, string) {
out := runCommandOutput(2*time.Second, "ip", "-6", "route", "show", "default")
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
iface := ""
gateway := ""
for i, field := range fields {
if field == "dev" && i+1 < len(fields) {
iface = fields[i+1]
}
if field == "via" && i+1 < len(fields) {
gateway = fields[i+1]
}
}
if iface != "" || gateway != "" {
return iface, gateway
}
}
return "", ""
}
func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe { func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe {
result := make([]HostIPProbe, 0) result := make([]HostIPProbe, 0)
for _, nic := range nics { for _, nic := range nics {
@@ -1301,6 +1734,16 @@ func isContainerLikeInterfaceName(iface string) bool {
return false return false
} }
func isTunnelLikeInterfaceName(iface string) bool {
lower := strings.ToLower(strings.TrimSpace(iface))
for _, prefix := range []string{"tun", "tap", "wg", "gre", "gretap", "sit", "ip6tnl", "he-", "zt", "tailscale"} {
if lower == prefix || strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe { func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe {
result := make([]HostIPProbe, 0) result := make([]HostIPProbe, 0)
for _, nic := range nics { for _, nic := range nics {
@@ -1368,6 +1811,8 @@ func detectGPUVendor(value string) string {
return "NVIDIA" return "NVIDIA"
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"): case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
return "AMD" return "AMD"
case strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu"):
return "Virtio"
default: default:
return "Unknown" return "Unknown"
} }
@@ -1375,6 +1820,9 @@ func detectGPUVendor(value string) string {
func detectGPUType(value string) string { func detectGPUType(value string) string {
lower := strings.ToLower(value) lower := strings.ToLower(value)
if strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu") {
return "virtual"
}
if strings.Contains(lower, "intel") { if strings.Contains(lower, "intel") {
return "integrated" return "integrated"
} }
@@ -1394,9 +1842,10 @@ func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe {
devKVM := fileExists("/dev/kvm") devKVM := fileExists("/dev/kvm")
nested, detail := detectNestedVirtualization() nested, detail := detectNestedVirtualization()
lxcOK := envCheckOK(env, "lxc-create") lxcOK := envCheckOK(env, "lxc-create")
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
probe := HostRuntimeProbe{ probe := HostRuntimeProbe{
LXCAvailable: lxcOK, LXCAvailable: lxcOK,
KVMAvailable: devKVM && envCheckOK(env, "virsh"), KVMAvailable: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()),
DevKVM: devKVM, DevKVM: devKVM,
NestedVirtualization: nested, NestedVirtualization: nested,
NestedDetail: detail, NestedDetail: detail,
@@ -1446,6 +1895,7 @@ func detectSystemProbe() HostSystemProbe {
} }
func detectHostEnvironment() []HostEnvCheck { func detectHostEnvironment() []HostEnvCheck {
qemuCheck := commandCheck(kvmQEMUCheckKey(), "QEMU/KVM 虚拟机", false, kvmQEMUCommand(), "")
checks := []HostEnvCheck{ checks := []HostEnvCheck{
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"), commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""), commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
@@ -1454,7 +1904,7 @@ func detectHostEnvironment() []HostEnvCheck {
commandCheck("ip", "iproute2 网络工具", true, "ip", ""), commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""), commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
commandCheck("virsh", "libvirt virsh", false, "virsh", ""), commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
commandCheck("qemu-system-x86_64", "QEMU/KVM 虚拟机", false, "qemu-system-x86_64", ""), qemuCheck,
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"), commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""), commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""), commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
@@ -1467,6 +1917,19 @@ func detectHostEnvironment() []HostEnvCheck {
return checks return checks
} }
func kvmQEMUCheckKey() string {
switch runtime.GOARCH {
case "arm64":
return "qemu-system-aarch64"
default:
return "qemu-system-x86_64"
}
}
func kvmQEMUCommand() string {
return kvmQEMUCheckKey()
}
func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck { func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck {
ok := commandExists(cmd) ok := commandExists(cmd)
detail := "missing" detail := "missing"
+34
View File
@@ -41,3 +41,37 @@ func TestCertbotVersionAtLeast54(t *testing.T) {
} }
} }
} }
func TestARMCPUModelName(t *testing.T) {
if got := armCPUModelName("0x41", "0xd0c"); got != "ARM Neoverse N1" {
t.Fatalf("armCPUModelName() = %q, want ARM Neoverse N1", got)
}
if got := armCPUModelName("41", "d0c"); got != "ARM Neoverse N1" {
t.Fatalf("armCPUModelName() without hex prefix = %q, want ARM Neoverse N1", got)
}
}
func TestMeaningfulCPUModel(t *testing.T) {
if meaningfulCPUModel("0") {
t.Fatal("numeric ARM processor index should not be treated as a CPU model")
}
if !meaningfulCPUModel("Neoverse-N1") {
t.Fatal("expected Neoverse-N1 to be treated as a CPU model")
}
}
func TestHostTrafficInterfaceFilter(t *testing.T) {
accepted := []string{"eth0", "ens3", "enp0s6", "bond0", "wg0"}
for _, name := range accepted {
if !isHostTrafficInterface(name) {
t.Fatalf("expected %s to be accepted as a host traffic interface", name)
}
}
rejected := []string{"", "lo", "docker0", "br-3024b78640ee", "lxcbr0", "virbr0", "vethaaa9e44", "cni0"}
for _, name := range rejected {
if isHostTrafficInterface(name) {
t.Fatalf("expected %s to be rejected as an internal/container interface", name)
}
}
}
+88 -4
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"sync" "sync"
"time" "time"
@@ -227,9 +228,14 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors() cleanupOldImageDownloadErrors()
kvmAvailable := hostKVMAvailable()
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages())) kvmImages := []kvm.Image{}
if kvmAvailable {
kvmImages = kvm.GetImages()
}
images := make([]ImageInfo, 0, len(templates)+len(kvmImages))
for _, t := range templates { for _, t := range templates {
dl := imageDownloadInfo(t.ID) dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
@@ -252,7 +258,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
SizeBytes: size, SizeBytes: size,
}) })
} }
for _, t := range kvm.GetImages() { for _, t := range kvmImages {
dl := imageDownloadInfo(t.ID) dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := "" manualPath := ""
@@ -309,6 +315,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return return
} }
if !hostKVMAvailable() {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
return
}
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok { if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID) ensureImageEnabled(image.ID)
clearImageDownload(image.ID) clearImageDownload(image.ID)
@@ -531,11 +541,36 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
runtime := runtimeFromRequest(r.URL.Query().Get("type")) runtime := runtimeFromRequest(r.URL.Query().Get("type"))
enabledSet := getEnabledImageSet() 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) result := make([]map[string]string, 0)
if runtime == config.VirtualizationKVM { if runtime == config.VirtualizationKVM {
if !hostKVMAvailable() {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
return
}
for _, t := range kvm.GetImages() { 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{ result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch, "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop, "description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
@@ -544,7 +579,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
} }
} else { } else {
for _, t := range lxc.GetTemplates() { 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{ result = append(result, map[string]string{
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch, "id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC, "variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
@@ -560,9 +598,48 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID)) 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 { func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime) runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM { if runtime == config.VirtualizationKVM {
if !hostKVMAvailable() {
return false
}
image := kvm.FindImage(templateID) image := kvm.FindImage(templateID)
if image == nil { if image == nil {
return false return false
@@ -579,6 +656,13 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
} }
func hostKVMAvailable() bool {
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return false
}
return fileExists("/dev/kvm") && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
}
func ensureImageEnabled(id string) { func ensureImageEnabled(id string) {
// If the enabled list is empty, all templates are currently enabled by default. // If the enabled list is empty, all templates are currently enabled by default.
// We must populate the list with all template IDs first so that explicit toggles stick. // We must populate the list with all template IDs first so that explicit toggles stick.
+42
View File
@@ -46,6 +46,19 @@ type ipv4Route struct {
Gateway string `json:"gateway,omitempty"` 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 { type ipv6Route struct {
ContainerID int `json:"container_id"` ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"` ContainerName string `json:"container_name"`
@@ -60,10 +73,12 @@ type routingResponse struct {
NAT4 routeCapacity `json:"nat4"` NAT4 routeCapacity `json:"nat4"`
NAT4PortRange nat4PortRange `json:"nat4_port_range"` NAT4PortRange nat4PortRange `json:"nat4_port_range"`
IPv4 routeCapacity `json:"ipv4"` IPv4 routeCapacity `json:"ipv4"`
LANDHCP routeCapacity `json:"lan_dhcp"`
IPv6 routeCapacity `json:"ipv6"` IPv6 routeCapacity `json:"ipv6"`
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"` HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"` PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
IPv4Assignments []ipv4Route `json:"ipv4_assignments"` IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
LANDHCPAssignments []lanDHCPRoute `json:"lan_dhcp_assignments"`
NAT4Mappings []nat4Route `json:"nat4_mappings"` NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"` IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"` IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
@@ -125,6 +140,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
nat4Mappings := make([]nat4Route, 0) nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{} usedPorts := map[int]bool{}
ipv4Assignments := make([]ipv4Route, 0) ipv4Assignments := make([]ipv4Route, 0)
lanDHCPAssignments := make([]lanDHCPRoute, 0)
ipv6Assignments := make([]ipv6Route, 0) ipv6Assignments := make([]ipv6Route, 0)
nat4StartPort, nat4EndPort := config.NATPortRange() nat4StartPort, nat4EndPort := config.NATPortRange()
@@ -164,6 +180,20 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
}) })
} }
c.NormalizeNetworkAssignments() 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 { for _, ip := range c.IPv6Addresses {
if ip.Address == "" { if ip.Address == "" {
continue continue
@@ -191,6 +221,12 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
sort.SliceStable(ipv4Assignments, func(i, j int) bool { sort.SliceStable(ipv4Assignments, func(i, j int) bool {
return ipv4Assignments[i].Address < ipv4Assignments[j].Address 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 { sort.SliceStable(ipv6Assignments, func(i, j int) bool {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address return ipv6Assignments[i].Address < ipv6Assignments[j].Address
}) })
@@ -231,6 +267,11 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
Remaining: strconv.Itoa(ipv4Remaining), Remaining: strconv.Itoa(ipv4Remaining),
Total: strconv.Itoa(ipv4Total), Total: strconv.Itoa(ipv4Total),
}, },
LANDHCP: routeCapacity{
Used: len(lanDHCPAssignments),
Remaining: "DHCP",
Total: "DHCP",
},
IPv6: routeCapacity{ IPv6: routeCapacity{
Used: len(ipv6Assignments), Used: len(ipv6Assignments),
Remaining: ipv6Remaining, Remaining: ipv6Remaining,
@@ -239,6 +280,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
HostPublicIPv4: hostPublicIPv4, HostPublicIPv4: hostPublicIPv4,
PublicIPv4Addresses: publicIPv4s, PublicIPv4Addresses: publicIPv4s,
IPv4Assignments: ipv4Assignments, IPv4Assignments: ipv4Assignments,
LANDHCPAssignments: lanDHCPAssignments,
NAT4Mappings: nat4Mappings, NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments, IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes, IPv6Prefixes: prefixes,
+1 -1
View File
@@ -20,7 +20,7 @@ func runtimeFromRequest(value string) string {
} }
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool { 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 { func runtimeFromTemplateID(templateID string) string {
+191 -2
View File
@@ -27,6 +27,9 @@ type subUserResponse struct {
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
ContainerNames []string `json:"container_names"` ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"` 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"` AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
@@ -38,6 +41,9 @@ func newSubUserResponse(su config.SubUser, password string) subUserResponse {
Password: password, Password: password,
ContainerNames: su.ContainerNames, ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs, ContainerUUIDs: su.ContainerUUIDs,
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
ImageLimitConfigured: su.ImageLimitConfigured,
CurrentImageIDs: subUserCurrentImageIDs(&su),
AccessCode: su.AccessCode, AccessCode: su.AccessCode,
CreatedAt: su.CreatedAt, CreatedAt: su.CreatedAt,
} }
@@ -94,6 +100,10 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
} }
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName) su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID) su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
if !su.ImageLimitConfigured && len(su.AllowedImageIDs) == 0 {
su.AllowedImageIDs = effectiveContainerAllowedImageIDs(c)
su.ImageLimitConfigured = true
}
config.SaveConfig() config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
@@ -120,6 +130,8 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
PassHash: string(hash), PassHash: string(hash),
ContainerNames: []string{containerName}, ContainerNames: []string{containerName},
ContainerUUIDs: []string{c.UUID}, ContainerUUIDs: []string{c.UUID},
AllowedImageIDs: effectiveContainerAllowedImageIDs(c),
ImageLimitConfigured: true,
AccessCode: accessCode, AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"), CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
} }
@@ -306,6 +318,155 @@ func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
return subUserAllowedContainers(r) 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 { func isAccessRestrictedRequest(r *http.Request) bool {
_, restricted := requestAllowedContainers(r) _, restricted := requestAllowedContainers(r)
return restricted return restricted
@@ -485,7 +646,7 @@ func isSubUserBlockedAction(action string, method string) bool {
return method != http.MethodGet return method != http.MethodGet
} }
switch action { switch action {
case "usage", "traffic": case "usage", "traffic", "history":
return method != http.MethodGet return method != http.MethodGet
default: default:
return true return true
@@ -504,7 +665,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
return method == http.MethodGet return method == http.MethodGet
} }
switch { switch {
case action == "usage" || action == "traffic" || action == "random-port": case action == "usage" || action == "traffic" || action == "history" || action == "random-port":
return method == http.MethodGet return method == http.MethodGet
case action == "snapshots": case action == "snapshots":
return method == http.MethodGet || method == http.MethodPost return method == http.MethodGet || method == http.MethodPost
@@ -584,6 +745,9 @@ type SubUserListItem struct {
Username string `json:"username"` Username string `json:"username"`
ContainerNames []string `json:"container_names"` ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"` 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"` ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"` ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"` AccessCode string `json:"access_code"`
@@ -611,6 +775,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
Username: su.Username, Username: su.Username,
ContainerNames: su.ContainerNames, ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs, ContainerUUIDs: su.ContainerUUIDs,
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
ImageLimitConfigured: su.ImageLimitConfigured,
CurrentImageIDs: subUserCurrentImageIDs(&su),
AccessCode: su.AccessCode, AccessCode: su.AccessCode,
Password: su.Password, Password: su.Password,
CreatedAt: su.CreatedAt, CreatedAt: su.CreatedAt,
@@ -711,6 +878,28 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
logs := filterSubUserLoginLogs(target.Username) logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs}) 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: default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
} }
+19 -1
View File
@@ -560,7 +560,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
if c := config.FindContainer(id); c != nil { if c := config.FindContainer(id); c != nil {
runtime = c.Runtime() 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"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return return
} }
@@ -646,6 +650,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
} }
req.Containers[i].NormalizeResourceAliases() req.Containers[i].NormalizeResourceAliases()
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization) 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 { if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512 req.Containers[i].RAMMB = 512
} }
@@ -656,6 +664,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return 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 { if req.Containers[i].PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
return return
@@ -777,6 +791,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return 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 taskConfig != nil {
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil { if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
+28 -12
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -127,7 +128,9 @@ var cliTranslations = map[string]string{
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.", "GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
"最新版本": "Latest version", "最新版本": "Latest version",
"发布页面": "Release page", "发布页面": "Release page",
"最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.", "当前架构不支持自动升级": "Automatic upgrade is not supported on the current architecture",
"最新 Release 没有找到": "The latest release does not contain",
"无法自动升级。": "automatic upgrade is unavailable.",
"当前已经是最新版本。": "The current version is already the latest.", "当前已经是最新版本。": "The current version is already the latest.",
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue", "是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
"输入 upgrade 开始升级": "Type upgrade to start upgrade", "输入 upgrade 开始升级": "Type upgrade to start upgrade",
@@ -557,11 +560,16 @@ func cliUpgradeSystem(reader *bufio.Reader) {
if repo == "" { if repo == "" {
repo = version.Repo repo = version.Repo
} }
assetName, err := releaseArchiveAssetName(runtime.GOARCH)
if err != nil {
cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH)
return
}
current := version.Current() current := version.Current()
cliPrintf("当前版本: %s\n", current) cliPrintf("当前版本: %s\n", current)
cliPrintf("检查仓库: https://github.com/%s\n", repo) cliPrintf("检查仓库: https://github.com/%s\n", repo)
release, err := fetchLatestRelease(repo) release, err := fetchLatestRelease(repo, assetName)
if err != nil { if err != nil {
cliPrintf("检查 GitHub 最新版本失败: %v\n", err) cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
return return
@@ -576,9 +584,9 @@ func cliUpgradeSystem(reader *bufio.Reader) {
cliPrintf("发布页面: %s\n", release.HTMLURL) cliPrintf("发布页面: %s\n", release.HTMLURL)
} }
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz") assetURL := findReleaseAsset(release, assetName)
if assetURL == "" { if assetURL == "" {
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。") cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName)
return return
} }
@@ -597,7 +605,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
} }
} }
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil { if err := upgradeFromReleaseAsset(assetURL, latest, assetName); err != nil {
cliPrintf("升级失败: %v\n", err) cliPrintf("升级失败: %v\n", err)
return return
} }
@@ -605,7 +613,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
cliPrintln("原有数据已保留,Web 服务已重启。") cliPrintln("原有数据已保留,Web 服务已重启。")
} }
func fetchLatestRelease(repo string) (*githubRelease, error) { func fetchLatestRelease(repo, assetName string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, err := http.NewRequest(http.MethodGet, url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
@@ -617,7 +625,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
client := &http.Client{Timeout: 20 * time.Second} client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil { if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
return fallback, nil return fallback, nil
} }
return nil, err return nil, err
@@ -627,7 +635,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body))) apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil { if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
cliPrintln("GitHub API 被限流,已切换到备用检查方式。") cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
} else { } else {
@@ -645,7 +653,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
return &release, nil return &release, nil
} }
func fetchLatestReleaseFallback(repo string) (*githubRelease, error) { func fetchLatestReleaseFallback(repo, assetName string) (*githubRelease, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil) req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -667,7 +675,6 @@ func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本") return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
} }
const assetName = "clicd-linux-amd64.tar.gz"
return &githubRelease{ return &githubRelease{
TagName: tag, TagName: tag,
Name: tag, Name: tag,
@@ -708,6 +715,15 @@ func setGitHubRequestHeaders(req *http.Request) {
} }
} }
func releaseArchiveAssetName(goarch string) (string, error) {
switch goarch {
case "amd64", "arm64":
return fmt.Sprintf("clicd-linux-%s.tar.gz", goarch), nil
default:
return "", fmt.Errorf("unsupported architecture: %s", goarch)
}
}
func findReleaseAsset(release *githubRelease, name string) string { func findReleaseAsset(release *githubRelease, name string) string {
for _, asset := range release.Assets { for _, asset := range release.Assets {
if asset.Name == name && asset.BrowserDownloadURL != "" { if asset.Name == name && asset.BrowserDownloadURL != "" {
@@ -717,14 +733,14 @@ func findReleaseAsset(release *githubRelease, name string) string {
return "" return ""
} }
func upgradeFromReleaseAsset(assetURL, latest string) error { func upgradeFromReleaseAsset(assetURL, latest, assetName string) error {
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*") tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
if err != nil { if err != nil {
return err return err
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz") archivePath := filepath.Join(tmpDir, assetName)
cliPrintln("正在下载升级包...") cliPrintln("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil { if err := downloadFile(assetURL, archivePath); err != nil {
return err return err
+20
View File
@@ -19,6 +19,26 @@ func TestSafeReleaseBackupComponent(t *testing.T) {
} }
} }
func TestReleaseArchiveAssetName(t *testing.T) {
tests := map[string]string{
"amd64": "clicd-linux-amd64.tar.gz",
"arm64": "clicd-linux-arm64.tar.gz",
}
for goarch, want := range tests {
got, err := releaseArchiveAssetName(goarch)
if err != nil {
t.Fatalf("releaseArchiveAssetName(%q) error = %v", goarch, err)
}
if got != want {
t.Fatalf("releaseArchiveAssetName(%q) = %q, want %q", goarch, got, want)
}
}
if _, err := releaseArchiveAssetName("386"); err == nil {
t.Fatal("releaseArchiveAssetName(386) error = nil, want unsupported architecture")
}
}
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) { func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
unsafeNames := []string{ unsafeNames := []string{
"../clicd", "../clicd",
+60
View File
@@ -129,6 +129,11 @@ type Container struct {
IOWriteMBps int `json:"io_write_mbps"` IOWriteMBps int `json:"io_write_mbps"`
Status string `json:"status"` Status string `json:"status"`
IP string `json:"ip"` 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"` PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
IPv6 string `json:"ipv6"` IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"` IPv6PrefixLen int `json:"ipv6_prefix_len"`
@@ -143,6 +148,8 @@ type Container struct {
FirewallEnabled bool `json:"firewall_enabled"` FirewallEnabled bool `json:"firewall_enabled"`
FirewallDefaultAction string `json:"firewall_default_action"` FirewallDefaultAction string `json:"firewall_default_action"`
FirewallRules []FirewallRule `json:"firewall_rules"` FirewallRules []FirewallRule `json:"firewall_rules"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
SnapshotLimit int `json:"snapshot_limit"` SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
@@ -160,6 +167,9 @@ type Container struct {
const ( const (
VirtualizationLXC = "lxc" VirtualizationLXC = "lxc"
VirtualizationKVM = "kvm" VirtualizationKVM = "kvm"
LANIPv4ModeDHCP = "dhcp"
LANIPv4ModeStatic = "static"
) )
func NormalizeVirtualization(value string) string { func NormalizeVirtualization(value string) string {
@@ -179,8 +189,56 @@ func (c *Container) IsKVM() bool {
return c.Runtime() == VirtualizationKVM 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 (c *Container) NormalizeNetworkAssignments() bool { func (c *Container) NormalizeNetworkAssignments() bool {
changed := false 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{} seenIPv4 := map[string]bool{}
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s)) filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
for _, item := range c.PublicIPv4s { for _, item := range c.PublicIPv4s {
@@ -325,6 +383,8 @@ type SubUser struct {
PassHash string `json:"pass_hash"` PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"` ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"` ContainerUUIDs []string `json:"container_uuids,omitempty"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
Token string `json:"-"` Token string `json:"-"`
AccessCode string `json:"access_code"` AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
+112 -29
View File
@@ -40,7 +40,14 @@ type savedTaskConfig struct {
ExtraPorts []int `json:"extra_ports"` ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"` PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"` 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"` SnapshotLimit int `json:"snapshot_limit"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
AssignIPv4 bool `json:"assign_ipv4"` AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"` IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"` PublicIPv4s []string `json:"public_ipv4s,omitempty"`
@@ -203,6 +210,11 @@ func ensureSchema() error {
io_write_mbps INTEGER NOT NULL DEFAULT 0, io_write_mbps INTEGER NOT NULL DEFAULT 0,
status TEXT, status TEXT,
ip TEXT, 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 TEXT,
ipv6_prefix_len INTEGER, ipv6_prefix_len INTEGER,
ipv6_interface TEXT, ipv6_interface TEXT,
@@ -222,7 +234,9 @@ func ensureSchema() error {
snapshot_schedule_created_by TEXT, snapshot_schedule_created_by TEXT,
policy_blocked INTEGER, policy_blocked INTEGER,
policy_blocked_reason TEXT, 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 ( `CREATE TABLE IF NOT EXISTS port_mappings (
container_id INTEGER NOT NULL, container_id INTEGER NOT NULL,
@@ -258,7 +272,9 @@ func ensureSchema() error {
pass_hash TEXT, pass_hash TEXT,
access_code TEXT, access_code TEXT,
created_at 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 ( `CREATE TABLE IF NOT EXISTS sub_user_container_names (
sub_user_id TEXT NOT NULL, sub_user_id TEXT NOT NULL,
@@ -338,6 +354,11 @@ func ensureSchema() error {
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0, cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
cfg_port_mapping_count INTEGER, cfg_port_mapping_count INTEGER,
cfg_assign_nat 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_snapshot_limit INTEGER,
cfg_assign_ipv4 INTEGER, cfg_assign_ipv4 INTEGER,
cfg_ipv4_count INTEGER, cfg_ipv4_count INTEGER,
@@ -348,6 +369,8 @@ func ensureSchema() error {
cfg_ssh_auth_mode TEXT, cfg_ssh_auth_mode TEXT,
cfg_ssh_password TEXT, cfg_ssh_password TEXT,
cfg_ssh_public_key TEXT, cfg_ssh_public_key TEXT,
cfg_allowed_image_ids TEXT,
cfg_image_limit_configured INTEGER NOT NULL DEFAULT 0,
cfg_expires_at TEXT cfg_expires_at TEXT
)`, )`,
`CREATE TABLE IF NOT EXISTS task_extra_ports ( `CREATE TABLE IF NOT EXISTS task_extra_ports (
@@ -410,14 +433,23 @@ func ensureSchemaMigrations() error {
{"tasks", "cfg_ipv4_count", "INTEGER"}, {"tasks", "cfg_ipv4_count", "INTEGER"},
{"tasks", "cfg_public_ipv4s", "TEXT"}, {"tasks", "cfg_public_ipv4s", "TEXT"},
{"tasks", "cfg_assign_nat", "INTEGER"}, {"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_count", "INTEGER"},
{"tasks", "cfg_ipv6_addresses", "TEXT"}, {"tasks", "cfg_ipv6_addresses", "TEXT"},
{"tasks", "cfg_ssh_auth_mode", "TEXT"}, {"tasks", "cfg_ssh_auth_mode", "TEXT"},
{"tasks", "cfg_ssh_password", "TEXT"}, {"tasks", "cfg_ssh_password", "TEXT"},
{"tasks", "cfg_ssh_public_key", "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"}, {"port_mappings", "host_ip", "TEXT"},
{"container_public_ipv4s", "prefix_len", "INTEGER"}, {"container_public_ipv4s", "prefix_len", "INTEGER"},
{"container_public_ipv4s", "gateway", "TEXT"}, {"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_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
@@ -425,6 +457,13 @@ func ensureSchemaMigrations() error {
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"}, {"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
{"containers", "firewall_rules", "TEXT"}, {"containers", "firewall_rules", "TEXT"},
{"containers", "allowed_image_ids", "TEXT"},
{"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
{"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) wasAdded, err := ensureColumn(column.table, column.name, column.def)
if err != nil { if err != nil {
@@ -466,6 +505,18 @@ func ensureSchemaMigrations() error {
return err return err
} }
} }
if _, err := db.Exec(`UPDATE containers
SET 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 tasks
SET 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 return nil
} }
@@ -677,30 +728,33 @@ func saveMeta(tx *sql.Tx) error {
func saveContainers(tx *sql.Tx) error { func saveContainers(tx *sql.Tx) error {
for _, c := range AppConfig.Containers { for _, c := range AppConfig.Containers {
NormalizeContainerResourceAliases(&c) NormalizeContainerResourceAliases(&c)
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
if _, err := tx.Exec(`INSERT INTO containers ( 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, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps, vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
monthly_traffic_gb, traffic_mode, traffic_in_gb, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
io_speed_mbps, io_read_mbps, io_write_mbps, io_speed_mbps, io_read_mbps, io_write_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password, status, 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, ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at, 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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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, c.MACAddress, c.Template,
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps, c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps, c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword, c.Status, 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, c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime, boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy, c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt, 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 { ); err != nil {
return err return err
} }
@@ -728,8 +782,9 @@ func saveContainers(tx *sql.Tx) error {
func saveSubUsers(tx *sql.Tx) error { func saveSubUsers(tx *sql.Tx) error {
for _, su := range AppConfig.SubUsers { for _, su := range AppConfig.SubUsers {
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version) allowedImageIDs := encodeStringSlice(su.AllowedImageIDs)
VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil { 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 return err
} }
for i, name := range su.ContainerNames { for i, name := range su.ContainerNames {
@@ -838,19 +893,21 @@ func saveTasksDB(tx *sql.Tx) error {
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps, cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, 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_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_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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent, 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.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps, cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps, 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.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), 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 { ); err != nil {
return err return err
} }
@@ -899,12 +956,13 @@ func loadContainers() ([]Container, error) {
monthly_traffic_gb, traffic_mode, traffic_in_gb, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
io_speed_mbps, io_read_mbps, io_write_mbps, io_speed_mbps, io_read_mbps, io_write_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password, status, 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, ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at, 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`) FROM containers ORDER BY id`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -914,31 +972,41 @@ func loadContainers() ([]Container, error) {
result := []Container{} result := []Container{}
for rows.Next() { for rows.Next() {
var c Container var c Container
var scheduleEnabled, policyBlocked, firewallEnabled int var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int
var firewallDefaultAction string var firewallDefaultAction string
var firewallRulesJSON sql.NullString var firewallRulesJSON, allowedImageIDs sql.NullString
var lanIPv4Address, lanIPv4Gateway sql.NullString
var lanIPv4PrefixLen sql.NullInt64
if err := rows.Scan( 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, &c.MACAddress, &c.Template,
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps, &c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps, &c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword, &c.Status, &c.IP, &c.LANIPv4Mode, &c.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, &c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime, &scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy, &c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt, &policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, &firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, &allowedImageIDs, &imageLimitConfigured,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
c.LANIPv4Address = lanIPv4Address.String
if lanIPv4PrefixLen.Valid {
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
}
c.LANIPv4Gateway = lanIPv4Gateway.String
c.SnapshotScheduleEnabled = scheduleEnabled != 0 c.SnapshotScheduleEnabled = scheduleEnabled != 0
c.PolicyBlocked = policyBlocked != 0 c.PolicyBlocked = policyBlocked != 0
c.FirewallEnabled = firewallEnabled != 0 c.FirewallEnabled = firewallEnabled != 0
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction) c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
c.ImageLimitConfigured = imageLimitConfigured != 0
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" { if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules) _ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
} }
c.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
NormalizeContainerResourceAliases(&c) NormalizeContainerResourceAliases(&c)
result = append(result, c) result = append(result, c)
} }
@@ -1034,7 +1102,7 @@ func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
} }
func loadSubUsers() ([]SubUser, 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 { if err != nil {
return nil, err return nil, err
} }
@@ -1042,9 +1110,13 @@ func loadSubUsers() ([]SubUser, error) {
result := []SubUser{} result := []SubUser{}
for rows.Next() { for rows.Next() {
var su SubUser 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 return nil, err
} }
su.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
su.ImageLimitConfigured = imageLimitConfigured != 0
result = append(result, su) result = append(result, su)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -1136,9 +1208,10 @@ func loadTasks() ([]SavedTask, error) {
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps, cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, 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_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_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`) FROM tasks ORDER BY created_at, id`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1149,19 +1222,20 @@ func loadTasks() ([]SavedTask, error) {
for rows.Next() { for rows.Next() {
var t SavedTask var t SavedTask
var cfg savedTaskConfig var cfg savedTaskConfig
var assignIPv4, assignIPv6 int var assignIPv4, assignIPv6, imageLimitConfigured int
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
var sshAuthMode, sshPassword, sshPublicKey sql.NullString var lanIPv4Mode, lanInterface, lanIPv4Address, lanIPv4Gateway, sshAuthMode, sshPassword, sshPublicKey, allowedImageIDs sql.NullString
var assignNAT, ipv4Count, ipv6Count sql.NullInt64 var assignNAT, lanIPv4PrefixLen, ipv4Count, ipv6Count sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent, &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.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps, &cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps, &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, &assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt, &sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -1171,6 +1245,13 @@ func loadTasks() ([]SavedTask, error) {
value := assignNAT.Int64 != 0 value := assignNAT.Int64 != 0
cfg.AssignNAT = &value 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 cfg.AssignIPv4 = assignIPv4 != 0
if ipv4Count.Valid { if ipv4Count.Valid {
cfg.IPv4Count = int(ipv4Count.Int64) cfg.IPv4Count = int(ipv4Count.Int64)
@@ -1184,6 +1265,8 @@ func loadTasks() ([]SavedTask, error) {
cfg.SSHAuthMode = sshAuthMode.String cfg.SSHAuthMode = sshAuthMode.String
cfg.SSHPassword = sshPassword.String cfg.SSHPassword = sshPassword.String
cfg.SSHPublicKey = sshPublicKey.String cfg.SSHPublicKey = sshPublicKey.String
cfg.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
cfg.ImageLimitConfigured = imageLimitConfigured != 0
normalizeSavedTaskConfigLimits(&cfg) normalizeSavedTaskConfigLimits(&cfg)
result = append(result, t) result = append(result, t)
configs = append(configs, cfg) configs = append(configs, cfg)
+73 -11
View File
@@ -20,6 +20,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -375,6 +376,10 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
if cfg.SnapshotLimit <= 0 { if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit cfg.SnapshotLimit = config.DefaultSnapshotLimit
} }
if !cfg.ImageLimitConfigured && len(cfg.AllowedImageIDs) == 0 && cfg.TemplateID != "" {
cfg.AllowedImageIDs = []string{cfg.TemplateID}
cfg.ImageLimitConfigured = true
}
id := config.AllocateContainerID() id := config.AllocateContainerID()
vmName := fmt.Sprintf("vm-%d", id) vmName := fmt.Sprintf("vm-%d", id)
@@ -568,6 +573,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}(), }(),
PortMappings: portMappings, PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount, PortMappingLimit: cfg.PortMappingCount,
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
ImageLimitConfigured: cfg.ImageLimitConfigured,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now, CreatedAt: now,
ExpiresAt: cfg.ExpiresAt, ExpiresAt: cfg.ExpiresAt,
@@ -1538,7 +1545,13 @@ func (m *Manager) validateHost(skipCloudInit bool) error {
return err return err
} }
} }
if err := requireCommand(kvmEmulatorCommand()); err != nil {
return err
}
if skipCloudInit { if skipCloudInit {
if runtime.GOARCH != "amd64" {
return fmt.Errorf("Windows KVM is currently supported only on x86_64/amd64 hosts")
}
if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil { if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil {
return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err) return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err)
} }
@@ -1572,6 +1585,40 @@ func requireAnyCommand(names ...string) error {
return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", ")) return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", "))
} }
func kvmLibvirtArch() string {
switch runtime.GOARCH {
case "arm64":
return "aarch64"
default:
return "x86_64"
}
}
func kvmMachineType() string {
switch runtime.GOARCH {
case "arm64":
return "virt"
default:
return "pc"
}
}
func kvmEmulatorCommand() string {
switch runtime.GOARCH {
case "arm64":
return "qemu-system-aarch64"
default:
return "qemu-system-x86_64"
}
}
func kvmEmulatorPath() string {
if path, err := exec.LookPath(kvmEmulatorCommand()); err == nil {
return path
}
return "/usr/bin/" + kvmEmulatorCommand()
}
func ensureDefaultNetwork() error { func ensureDefaultNetwork() error {
// Ensure libvirtd is running // Ensure libvirtd is running
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil { if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
@@ -2186,6 +2233,26 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>" video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
input = "\n\t <input type='tablet' bus='usb'/>" input = "\n\t <input type='tablet' bus='usb'/>"
} }
osAttrs := ""
features := "<features><acpi/><apic/></features>"
if runtime.GOARCH == "arm64" {
osAttrs = " firmware='efi'"
features = "<features><acpi/><gic version='3'/></features>"
}
seedDisk := fmt.Sprintf(`<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='hdb' bus='ide'/>
<readonly/>
</disk>`, xmlEscape(seedPath))
if runtime.GOARCH == "arm64" {
seedDisk = fmt.Sprintf(`<disk type='file' device='disk'>
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='vdb' bus='virtio'/>
<readonly/>
</disk>`, xmlEscape(seedPath))
}
return fmt.Sprintf(`<domain type='kvm'> return fmt.Sprintf(`<domain type='kvm'>
<name>%s</name> <name>%s</name>
%s %s
@@ -2193,29 +2260,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<currentMemory unit='MiB'>%d</currentMemory> <currentMemory unit='MiB'>%d</currentMemory>
<vcpu placement='static' current='%d'>%d</vcpu> <vcpu placement='static' current='%d'>%d</vcpu>
<cputune><shares>2048</shares></cputune> <cputune><shares>2048</shares></cputune>
<os> <os%s>
<type arch='x86_64' machine='pc'>hvm</type> <type arch='%s' machine='%s'>hvm</type>
<boot dev='hd'/> <boot dev='hd'/>
</os> </os>
<features><acpi/><apic/></features> %s
<cpu mode='host-passthrough' check='none'/> <cpu mode='host-passthrough' check='none'/>
<clock offset='utc'/> <clock offset='utc'/>
<on_poweroff>destroy</on_poweroff> <on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot> <on_reboot>restart</on_reboot>
<on_crash>restart</on_crash> <on_crash>restart</on_crash>
<devices> <devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator> <emulator>%s</emulator>
<disk type='file' device='disk'> <disk type='file' device='disk'>
<driver name='qemu' type='qcow2' cache='none'/> <driver name='qemu' type='qcow2' cache='none'/>
<source file='%s'/> <source file='%s'/>
<target dev='vda' bus='virtio'/>%s <target dev='vda' bus='virtio'/>%s
</disk> </disk>
<disk type='file' device='cdrom'> %s
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='hdb' bus='ide'/>
<readonly/>
</disk>
<interface type='network'> <interface type='network'>
<mac address='%s'/> <mac address='%s'/>
<source network='default'/> <source network='default'/>
@@ -2232,7 +2294,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s <graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
%s %s
</devices> </devices>
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video) </domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, osAttrs, kvmLibvirtArch(), kvmMachineType(), features, xmlEscape(kvmEmulatorPath()), xmlEscape(diskPath), iotune, seedDisk, xmlEscape(mac), bandwidth, input, video)
} }
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string { func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string {
+76
View File
@@ -2,6 +2,7 @@ package kvm
import ( import (
"path/filepath" "path/filepath"
"runtime"
) )
type Image struct { type Image struct {
@@ -16,6 +17,15 @@ type Image struct {
} }
func GetImages() []Image { func GetImages() []Image {
switch runtime.GOARCH {
case "arm64":
return arm64Images()
default:
return amd64Images()
}
}
func amd64Images() []Image {
return []Image{ return []Image{
{ {
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM", ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
@@ -36,12 +46,25 @@ func GetImages() []Image {
Description: "Ubuntu 22.04 LTS cloud image for KVM", Description: "Ubuntu 22.04 LTS cloud image for KVM",
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img", 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", ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64", Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 generic cloud image for KVM", Description: "Debian 12 generic cloud image for KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2", 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", ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
Distro: "debian", Release: "bookworm", Arch: "amd64", Distro: "debian", Release: "bookworm", Arch: "amd64",
@@ -94,6 +117,59 @@ func GetImages() []Image {
} }
} }
func arm64Images() []Image {
return []Image{
{
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
Distro: "ubuntu", Release: "noble", Arch: "arm64",
Description: "Ubuntu 24.04 LTS cloud image for ARM64 KVM",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
},
{
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "arm64",
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",
Description: "Debian 12 generic cloud image for ARM64 KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2",
},
{
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "arm64",
Description: "Debian 11 generic cloud image for ARM64 KVM",
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2",
},
{
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
Distro: "centos", Release: "9-stream", Arch: "arm64",
Description: "CentOS Stream 9 GenericCloud image for ARM64 KVM",
URL: "https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2",
},
{
ID: "kvm-fedora-44", Name: "Fedora 44 KVM",
Distro: "fedora", Release: "44", Arch: "arm64",
Description: "Fedora 44 GenericCloud image for ARM64 KVM",
URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2",
},
{
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
Distro: "rockylinux", Release: "9", Arch: "arm64",
Description: "Rocky Linux 9 GenericCloud image for ARM64 KVM",
URL: "https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud-Base.latest.aarch64.qcow2",
},
}
}
func FindImage(id string) *Image { func FindImage(id string) *Image {
for _, image := range GetImages() { for _, image := range GetImages() {
if image.ID == id { if image.ID == id {
+410 -12
View File
@@ -8,6 +8,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"hash/fnv"
"net/netip"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -81,6 +83,13 @@ func (m *Manager) WarmRunningContainersSSH() {
continue continue
} }
config.UpdateContainerStatus(c.ID, "running") 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) { if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
continue continue
} }
@@ -238,7 +247,14 @@ type ContainerConfig struct {
ExtraPorts []int `json:"extra_ports"` ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"` PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"` 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"` SnapshotLimit int `json:"snapshot_limit"`
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
AssignIPv4 bool `json:"assign_ipv4"` AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"` IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"` PublicIPv4s []string `json:"public_ipv4s,omitempty"`
@@ -287,9 +303,24 @@ func (cfg *ContainerConfig) NormalizeResourceAliases() {
} }
func (cfg ContainerConfig) WantsNAT() bool { func (cfg ContainerConfig) WantsNAT() bool {
if cfg.WantsLANIPv4() {
return false
}
return cfg.AssignNAT == nil || *cfg.AssignNAT 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. // CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
func (m *Manager) CreateContainer(cfg ContainerConfig) error { func (m *Manager) CreateContainer(cfg ContainerConfig) error {
cfg.NormalizeResourceAliases() cfg.NormalizeResourceAliases()
@@ -306,6 +337,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if cfg.SnapshotLimit <= 0 { if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit 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) { if !config.IsValidContainerName(cfg.Name) {
return fmt.Errorf("invalid container name: %s", cfg.Name) return fmt.Errorf("invalid container name: %s", cfg.Name)
@@ -349,6 +384,14 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
_ = m.cleanupContainerStorage(lxcName) _ = m.cleanupContainerStorage(lxcName)
return err return err
} }
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. // Apply resource limits and mandatory security hardening.
if err := m.applyResourceLimits(lxcName, cfg); err != nil { if err := m.applyResourceLimits(lxcName, cfg); err != nil {
@@ -428,6 +471,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
UUID: config.NewContainerUUID(), UUID: config.NewContainerUUID(),
Name: cfg.Name, Name: cfg.Name,
Virtualization: config.VirtualizationLXC, Virtualization: config.VirtualizationLXC,
LXCName: lxcName,
Template: cfg.TemplateID, Template: cfg.TemplateID,
VCPU: cfg.VCPU, VCPU: cfg.VCPU,
RAMMB: cfg.RAMMB, RAMMB: cfg.RAMMB,
@@ -445,6 +489,12 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
IOWriteMBps: cfg.IOWriteMBps, IOWriteMBps: cfg.IOWriteMBps,
Status: "stopped", Status: "stopped",
IP: "", 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, PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments, IPv6Addresses: ipv6Assignments,
VNCPort: 0, VNCPort: 0,
@@ -452,6 +502,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
SSHPassword: sshPassword, SSHPassword: sshPassword,
PortMappings: portMappings, PortMappings: portMappings,
PortMappingLimit: cfg.PortMappingCount, PortMappingLimit: cfg.PortMappingCount,
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
ImageLimitConfigured: cfg.ImageLimitConfigured,
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now, CreatedAt: now,
ExpiresAt: cfg.ExpiresAt, ExpiresAt: cfg.ExpiresAt,
@@ -461,7 +513,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
// Pre-configure network and SSH in the rootfs before first boot. // Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, cfg.TemplateID) m.preconfigureNetwork(rootfsPath, cfg)
if len(ipv6Assignments) > 0 { if len(ipv6Assignments) > 0 {
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil { if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err) fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
@@ -494,7 +546,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return nil return nil
} }
func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) { func (m *Manager) preconfigureNetwork(rootfsPath string, cfg ContainerConfig) {
templateID := cfg.TemplateID
osRelease := "" osRelease := ""
if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil { if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil {
osRelease = strings.ToLower(string(data)) osRelease = strings.ToLower(string(data))
@@ -510,7 +563,13 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
if isAlpine { if isAlpine {
interfaces := filepath.Join(rootfsPath, "etc", "network", "interfaces") 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.MkdirAll(filepath.Dir(interfaces), 0755)
_ = os.WriteFile(interfaces, []byte(content), 0644) _ = os.WriteFile(interfaces, []byte(content), 0644)
_ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot") _ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot")
@@ -527,8 +586,16 @@ interface-name=eth0
autoconnect=true autoconnect=true
[ipv4] [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] [ipv6]
method=ignore method=ignore
` `
@@ -544,9 +611,14 @@ method=ignore
Name=eth0 Name=eth0
[Network] [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 IPv6AcceptRA=no
` `
}
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644) _ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
} }
if !isRHELFamily { if !isRHELFamily {
@@ -554,6 +626,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)+len(values))
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. // preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error { func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error {
_ = templateID _ = templateID
@@ -632,8 +924,7 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
// Keep sys_admin: unprivileged containers need it to mount tmpfs (/dev/shm, /run, etc.) // Keep sys_admin: unprivileged containers need it to mount tmpfs (/dev/shm, /run, etc.)
// All capabilities are already confined to the container's user namespace. // All capabilities are already confined to the container's user namespace.
newLines = append(newLines, "lxc.cap.drop = mac_admin mac_override sys_module sys_rawio sys_time sys_boot sys_nice sys_resource sys_ptrace sys_pacct mknod audit_control audit_read") newLines = append(newLines, "lxc.cap.drop = mac_admin mac_override sys_module sys_rawio sys_time sys_boot sys_nice sys_resource sys_ptrace sys_pacct mknod audit_control audit_read")
newLines = append(newLines, "lxc.prlimit.nofile = 1024:4096") newLines = append(newLines, managedPrlimitLines()...)
newLines = append(newLines, "lxc.prlimit.nproc = 128:256")
newLines = append(newLines, "", "# clicd managed resource limits (cgroup v2)") newLines = append(newLines, "", "# clicd managed resource limits (cgroup v2)")
if cfg.VCPU > 0 { if cfg.VCPU > 0 {
@@ -663,6 +954,13 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
return nil return nil
} }
func managedPrlimitLines() []string {
// Do not set lxc.prlimit.nproc for unprivileged containers: RLIMIT_NPROC is
// accounted by the host-mapped UID, so containers sharing a uid_map would
// consume one shared quota and fail to fork/exec during batch starts.
return []string{"lxc.prlimit.nofile = 1024:4096"}
}
func (m *Manager) ioLimitLines(lxcName string, readMBps int, writeMBps int) ([]string, error) { func (m *Manager) ioLimitLines(lxcName string, readMBps int, writeMBps int) ([]string, error) {
if readMBps < 0 { if readMBps < 0 {
readMBps = 0 readMBps = 0
@@ -1446,6 +1744,7 @@ func (m *Manager) StartContainer(id int) error {
c = config.FindContainer(id) c = config.FindContainer(id)
if c != nil { if c != nil {
c.IP = ip c.IP = ip
m.refreshContainerIPv4Details(c)
config.SaveConfig() config.SaveConfig()
} }
} }
@@ -1459,6 +1758,9 @@ func (m *Manager) StartContainer(id int) error {
} }
if ip != "" { if ip != "" {
if err := m.ensureLANHostAccess(c); err != nil {
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", lxcName, err)
}
if err := m.EnsureSSH(id); err != nil { if err := m.EnsureSSH(id); err != nil {
return err return err
} }
@@ -1482,7 +1784,6 @@ func (m *Manager) StartContainer(id int) error {
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err) 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) fmt.Printf("Container %d (%s) started, IP: %s\n", id, c.Name, ip)
return nil return nil
} }
@@ -1523,13 +1824,35 @@ func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error {
} }
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface // applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container. // ApplyContainerLimits re-applies persisted LXC config limits and, when running,
// runtime cgroup/tc limits.
func (m *Manager) ApplyContainerLimits(c *config.Container) error { func (m *Manager) ApplyContainerLimits(c *config.Container) error {
if c == nil || c.Status != "running" { if c == nil {
return nil return nil
} }
config.NormalizeContainerResourceAliases(c) config.NormalizeContainerResourceAliases(c)
lxcName := c.LxcName() lxcName := c.LxcName()
if err := m.applyResourceLimits(lxcName, ContainerConfig{
Name: c.Name,
TemplateID: c.Template,
VCPU: c.VCPU,
RAMMB: c.RAMMB,
DiskGB: c.DiskGB,
NetworkBWMbps: c.NetworkBWMbps,
NetworkDownMbps: c.NetworkDownMbps,
NetworkUpMbps: c.NetworkUpMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps,
IOReadMBps: c.IOReadMBps,
IOWriteMBps: c.IOWriteMBps,
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt,
}); err != nil {
return err
}
if c.Status != "running" {
return nil
}
// CPU: write cpu.max // CPU: write cpu.max
cpuQuota := int(c.VCPU * 100000) cpuQuota := int(c.VCPU * 100000)
@@ -1737,6 +2060,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) return "", fmt.Errorf("no IPv4 address after DHCP repair in %s", lxcName)
} }
c.IP = ip c.IP = ip
m.refreshContainerIPv4Details(c)
config.SaveConfig() config.SaveConfig()
return ip, nil return ip, nil
} }
@@ -1758,6 +2082,10 @@ func (m *Manager) WarmSSH(id int) error {
if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" { if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" {
if current := config.FindContainer(id); current != nil { if current := config.FindContainer(id); current != nil {
current.IP = 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() config.SaveConfig()
} }
break break
@@ -1767,6 +2095,10 @@ func (m *Manager) WarmSSH(id int) error {
if current := config.FindContainer(id); current != nil && current.IP == "" { if current := config.FindContainer(id); current != nil && current.IP == "" {
if ip, err := m.EnsureContainerIPv4(id); err == nil && ip != "" { if ip, err := m.EnsureContainerIPv4(id); err == nil && ip != "" {
current.IP = 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() config.SaveConfig()
} }
} }
@@ -2514,6 +2846,71 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
return "", fmt.Errorf("no IPv4 address found for %s (IPv6 is disabled for containers)", lxcName) 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 // ListContainers lists all LXC containers and updates statuses
func (m *Manager) ListContainers() ([]config.Container, error) { func (m *Manager) ListContainers() ([]config.Container, error) {
containers := config.AppConfig.Containers containers := config.AppConfig.Containers
@@ -2529,6 +2926,7 @@ func (m *Manager) ListContainers() ([]config.Container, error) {
ip, err := m.GetContainerIP(containers[i].LxcName()) ip, err := m.GetContainerIP(containers[i].LxcName())
if err == nil { if err == nil {
containers[i].IP = ip containers[i].IP = ip
m.refreshContainerIPv4Details(&containers[i])
} }
} }
} }
@@ -2788,7 +3186,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
// Set root password and pre-configure network/SSH via chroot. // Set root password and pre-configure network/SSH via chroot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, templateID) m.preconfigureNetwork(rootfsPath, ContainerConfig{TemplateID: templateID})
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil { if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err) fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
+8
View File
@@ -113,6 +113,14 @@ open_by_handle_at errno 1
} }
} }
func TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) {
for _, line := range managedPrlimitLines() {
if strings.HasPrefix(strings.TrimSpace(line), "lxc.prlimit.nproc") {
t.Fatalf("managed prlimit lines must not set nproc: %q", line)
}
}
}
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) { func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n" base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
+28
View File
@@ -252,6 +252,7 @@ func EnsureForwardRules(bridge string) {
if bridge == "" { if bridge == "" {
bridge = "lxcbr0" bridge = "lxcbr0"
} }
ensureLibvirtForwardRules(bridge)
rules := [][]string{ rules := [][]string{
{"-i", bridge, "-j", "ACCEPT"}, {"-i", bridge, "-j", "ACCEPT"},
{"-o", 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 // CleanPortMappings removes all iptables rules for a container
func (m *Manager) CleanPortMappings(id int) error { func (m *Manager) CleanPortMappings(id int) error {
tag := clicdTag(id) tag := clicdTag(id)
+26 -9
View File
@@ -1,5 +1,7 @@
package lxc package lxc
import "runtime"
// Template represents an LXC image template // Template represents an LXC image template
type Template struct { type Template struct {
ID string `json:"id"` ID string `json:"id"`
@@ -13,55 +15,70 @@ type Template struct {
// GetTemplates returns available LXC image templates (only verified working ones) // GetTemplates returns available LXC image templates (only verified working ones)
func GetTemplates() []Template { func GetTemplates() []Template {
arch := defaultTemplateArch()
return []Template{ return []Template{
{ {
ID: "ubuntu-noble", Name: "Ubuntu 24.04", ID: "ubuntu-noble", Name: "Ubuntu 24.04",
Distro: "ubuntu", Release: "noble", Arch: "amd64", Distro: "ubuntu", Release: "noble", Arch: arch,
Description: "Ubuntu 24.04 LTS", Description: "Ubuntu 24.04 LTS",
}, },
{ {
ID: "ubuntu-jammy", Name: "Ubuntu 22.04", ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
Distro: "ubuntu", Release: "jammy", Arch: "amd64", Distro: "ubuntu", Release: "jammy", Arch: arch,
Description: "Ubuntu 22.04 LTS", 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", ID: "debian-bookworm", Name: "Debian 12",
Distro: "debian", Release: "bookworm", Arch: "amd64", Distro: "debian", Release: "bookworm", Arch: arch,
Description: "Debian 12 (Bookworm)", Description: "Debian 12 (Bookworm)",
}, },
{ {
ID: "debian-bullseye", Name: "Debian 11", ID: "debian-bullseye", Name: "Debian 11",
Distro: "debian", Release: "bullseye", Arch: "amd64", Distro: "debian", Release: "bullseye", Arch: arch,
Description: "Debian 11 (Bullseye)", Description: "Debian 11 (Bullseye)",
}, },
{ {
ID: "alpine-3.21", Name: "Alpine 3.21", ID: "alpine-3.21", Name: "Alpine 3.21",
Distro: "alpine", Release: "3.21", Arch: "amd64", Distro: "alpine", Release: "3.21", Arch: arch,
Description: "Alpine Linux 3.21", Description: "Alpine Linux 3.21",
}, },
{ {
ID: "centos-9-stream", Name: "CentOS 9 Stream", ID: "centos-9-stream", Name: "CentOS 9 Stream",
Distro: "centos", Release: "9-Stream", Arch: "amd64", Distro: "centos", Release: "9-Stream", Arch: arch,
Description: "CentOS 9 Stream", Description: "CentOS 9 Stream",
}, },
{ {
ID: "archlinux-current", Name: "Arch Linux", ID: "archlinux-current", Name: "Arch Linux",
Distro: "archlinux", Release: "current", Arch: "amd64", Distro: "archlinux", Release: "current", Arch: arch,
Description: "Arch Linux (Rolling)", Description: "Arch Linux (Rolling)",
}, },
{ {
ID: "fedora-44", Name: "Fedora 44", ID: "fedora-44", Name: "Fedora 44",
Distro: "fedora", Release: "44", Arch: "amd64", Distro: "fedora", Release: "44", Arch: arch,
Description: "Fedora 44", Description: "Fedora 44",
}, },
{ {
ID: "rockylinux-10", Name: "Rocky Linux 10", ID: "rockylinux-10", Name: "Rocky Linux 10",
Distro: "rockylinux", Release: "10", Arch: "amd64", Distro: "rockylinux", Release: "10", Arch: arch,
Description: "Rocky Linux 10", Description: "Rocky Linux 10",
}, },
} }
} }
func defaultTemplateArch() string {
switch runtime.GOARCH {
case "arm64":
return "arm64"
default:
return "amd64"
}
}
// FindTemplate finds a template by ID // FindTemplate finds a template by ID
func FindTemplate(id string) *Template { func FindTemplate(id string) *Template {
templates := GetTemplates() templates := GetTemplates()
+4
View File
@@ -61,6 +61,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard))) mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/host-history", corsMiddleware(api.AdminMiddleware(api.HandleHostHistory)))
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport))) mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan))) mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
@@ -104,6 +105,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle))) 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/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-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/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/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/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
@@ -174,6 +176,8 @@ func setupRoutes(mux *http.ServeMux) {
func Run() error { func Run() error {
// Use embedded frontend files // Use embedded frontend files
webFS = GetEmbeddedFS() webFS = GetEmbeddedFS()
api.StartHostMetricSampler()
api.StartContainerMetricSampler()
mux := http.NewServeMux() mux := http.NewServeMux()
setupRoutes(mux) setupRoutes(mux)
+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.21" Version = "1.1.24"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+41 -3
View File
@@ -6,6 +6,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build" BUILD_DIR="$SCRIPT_DIR/build"
DIST_DIR="$SCRIPT_DIR/dist"
FRONTEND_DIR="$SCRIPT_DIR/frontend" FRONTEND_DIR="$SCRIPT_DIR/frontend"
BACKEND_DIR="$SCRIPT_DIR/backend" BACKEND_DIR="$SCRIPT_DIR/backend"
WEB_DIR="$SCRIPT_DIR/web" WEB_DIR="$SCRIPT_DIR/web"
@@ -17,9 +18,11 @@ echo "====================================="
# Clean previous build # Clean previous build
rm -rf "$BUILD_DIR" rm -rf "$BUILD_DIR"
rm -rf "$DIST_DIR"
rm -rf "$WEB_DIR" rm -rf "$WEB_DIR"
rm -rf "$EMBED_WEB_DIR" rm -rf "$EMBED_WEB_DIR"
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
mkdir -p "$DIST_DIR"
mkdir -p "$WEB_DIR" mkdir -p "$WEB_DIR"
mkdir -p "$EMBED_WEB_DIR" mkdir -p "$EMBED_WEB_DIR"
touch "$EMBED_WEB_DIR/.gitkeep" touch "$EMBED_WEB_DIR/.gitkeep"
@@ -51,9 +54,26 @@ cd "$BACKEND_DIR"
go mod tidy go mod tidy
go mod download go mod download
# Build for Linux amd64
BUILD_VERSION="${CLICD_VERSION:-dev}" BUILD_VERSION="${CLICD_VERSION:-dev}"
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" . TARGET_GOOS="${CLICD_GOOS:-linux}"
TARGET_GOARCH="${CLICD_GOARCH:-amd64}"
case "$TARGET_GOARCH" in
all) TARGET_GOARCH_LIST="amd64 arm64" ;;
amd64|arm64) TARGET_GOARCH_LIST="$TARGET_GOARCH" ;;
*)
echo "Unsupported CLICD_GOARCH: $TARGET_GOARCH (expected amd64, arm64, or all)" >&2
exit 2
;;
esac
for arch in $TARGET_GOARCH_LIST; do
echo "Target: ${TARGET_GOOS}/${arch}"
GOOS="$TARGET_GOOS" GOARCH="$arch" CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd-linux-${arch}" .
done
first_arch="${TARGET_GOARCH_LIST%% *}"
cp "$BUILD_DIR/clicd-linux-${first_arch}" "$BUILD_DIR/clicd"
echo "Go backend built successfully" echo "Go backend built successfully"
@@ -62,7 +82,20 @@ echo ""
echo "[3/3] Packaging..." echo "[3/3] Packaging..."
cp -r "$WEB_DIR" "$BUILD_DIR/web" cp -r "$WEB_DIR" "$BUILD_DIR/web"
cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true
chmod +x "$BUILD_DIR/clicd" chmod +x "$BUILD_DIR"/clicd*
for arch in $TARGET_GOARCH_LIST; do
asset_dir="clicd-linux-${arch}"
package_root="$BUILD_DIR/package-${arch}"
rm -rf "$package_root"
mkdir -p "$package_root/$asset_dir"
cp "$BUILD_DIR/clicd-linux-${arch}" "$package_root/$asset_dir/clicd"
cp "$BUILD_DIR/install.sh" "$package_root/$asset_dir/install.sh" 2>/dev/null || true
chmod +x "$package_root/$asset_dir/clicd"
[ ! -f "$package_root/$asset_dir/install.sh" ] || chmod +x "$package_root/$asset_dir/install.sh"
tar -C "$package_root" -czf "$DIST_DIR/${asset_dir}.tar.gz" "$asset_dir"
cp "$BUILD_DIR/clicd-linux-${arch}" "$DIST_DIR/${asset_dir}"
done
echo "" echo ""
echo "=====================================" echo "====================================="
@@ -70,6 +103,11 @@ echo " Build Complete!"
echo "=====================================" echo "====================================="
echo " Output: $BUILD_DIR/clicd" echo " Output: $BUILD_DIR/clicd"
echo " Web: $BUILD_DIR/web/" echo " Web: $BUILD_DIR/web/"
echo " Dist: $DIST_DIR/"
for arch in $TARGET_GOARCH_LIST; do
echo " dist/clicd-linux-${arch}"
echo " dist/clicd-linux-${arch}.tar.gz"
done
echo "" echo ""
echo " To deploy:" echo " To deploy:"
echo " 1. Copy build/ directory to server" echo " 1. Copy build/ directory to server"
+19
View File
@@ -30,6 +30,25 @@ bash build.sh
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。 该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
默认目标为 Linux amd64。需要构建 ARM64 包时可以指定:
```bash
CLICD_GOARCH=arm64 bash build.sh
```
需要同时构建 amd64 和 arm64 发布包时:
```bash
CLICD_GOARCH=all bash build.sh
```
构建完成后会生成:
- `dist/clicd-linux-amd64`
- `dist/clicd-linux-amd64.tar.gz`
- `dist/clicd-linux-arm64`
- `dist/clicd-linux-arm64.tar.gz`
## 文档站构建 ## 文档站构建
```bash ```bash
+3 -1
View File
@@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语
## Release 产物 ## Release 产物
安装脚本会优先下载 Linux AMD64 产物: 安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物:
```text ```text
clicd-linux-amd64.tar.gz clicd-linux-amd64.tar.gz
clicd-linux-arm64.tar.gz
``` ```
在部分场景中也会尝试下载单独二进制: 在部分场景中也会尝试下载单独二进制:
```text ```text
clicd-linux-amd64 clicd-linux-amd64
clicd-linux-arm64
``` ```
## 安装脚本行为 ## 安装脚本行为
+19
View File
@@ -30,6 +30,25 @@ bash build.sh
The script chains frontend build, static asset sync, and Go binary build. The script chains frontend build, static asset sync, and Go binary build.
The default target is Linux amd64. To build an ARM64 package, set:
```bash
CLICD_GOARCH=arm64 bash build.sh
```
To build both amd64 and arm64 release assets at once:
```bash
CLICD_GOARCH=all bash build.sh
```
The build writes:
- `dist/clicd-linux-amd64`
- `dist/clicd-linux-amd64.tar.gz`
- `dist/clicd-linux-arm64`
- `dist/clicd-linux-arm64.tar.gz`
## Docs Build ## Docs Build
```bash ```bash
+3 -1
View File
@@ -12,16 +12,18 @@ Check the version in:
## Release Artifacts ## Release Artifacts
The installer first tries to download the Linux AMD64 archive: The installer first tries to download the Linux AMD64 or ARM64 archive for the host architecture:
```text ```text
clicd-linux-amd64.tar.gz clicd-linux-amd64.tar.gz
clicd-linux-arm64.tar.gz
``` ```
In some cases, it may also try the standalone binary: In some cases, it may also try the standalone binary:
```text ```text
clicd-linux-amd64 clicd-linux-amd64
clicd-linux-arm64
``` ```
## Installer Behavior ## Installer Behavior
+2 -2
View File
@@ -4,7 +4,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
## Requirements ## Requirements
- Linux x86_64 host. - Linux x86_64/amd64 or ARM64/aarch64 host.
- Root privileges. - Root privileges.
- systemd. - systemd.
- Network access to GitHub Release downloads. - Network access to GitHub Release downloads.
@@ -17,7 +17,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
``` ```
The script defaults to `CLICD_VERSION=latest`, which downloads `clicd-linux-amd64.tar.gz` from `releases/latest`. The script defaults to `CLICD_VERSION=latest` and downloads `clicd-linux-amd64.tar.gz` or `clicd-linux-arm64.tar.gz` from `releases/latest` according to the host architecture.
## Install a Specific Version ## Install a Specific Version
+1 -1
View File
@@ -26,4 +26,4 @@ CLICD is a lightweight virtualization management panel for LXC and KVM. It bring
- Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack. - Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack.
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC. - Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC.
- Release: GitHub Actions builds Linux AMD64 release artifacts. The installer fetches the latest release by default. - Release: GitHub Actions builds Linux AMD64/ARM64 release artifacts. The installer fetches the latest release by default.
+1 -1
View File
@@ -2,7 +2,7 @@
## Which version does the installer install by default? ## Which version does the installer install by default?
It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 artifact from `releases/latest`. It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 or ARM64 artifact from `releases/latest` according to the host architecture.
## Can I pin a specific version? ## Can I pin a specific version?
+2 -2
View File
@@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
## 环境要求 ## 环境要求
- Linux x86_64 宿主机。 - Linux x86_64/amd64 或 ARM64/aarch64 宿主机。
- root 权限。 - root 权限。
- systemd。 - systemd。
- 网络可访问 GitHub Release 下载地址。 - 网络可访问 GitHub Release 下载地址。
@@ -17,7 +17,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
``` ```
脚本当前默认使用 `CLICD_VERSION=latest`也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz` 脚本当前默认使用 `CLICD_VERSION=latest`会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz``clicd-linux-arm64.tar.gz`
## 安装指定版本 ## 安装指定版本
+1 -1
View File
@@ -26,4 +26,4 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。 - 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。 - 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。 - 发布:GitHub Actions 构建 Linux AMD64/ARM64 release 产物,安装脚本默认拉取最新 Release。
+1 -1
View File
@@ -2,7 +2,7 @@
## 安装脚本默认安装哪个版本? ## 安装脚本默认安装哪个版本?
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。 默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。
## 可以固定安装某个版本吗? ## 可以固定安装某个版本吗?
+3 -3
View File
@@ -2475,9 +2475,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -11,7 +11,7 @@
"vitepress": "^1.6.4" "vitepress": "^1.6.4"
}, },
"overrides": { "overrides": {
"vite": "6.4.2", "vite": "6.4.3",
"esbuild": "0.28.1" "esbuild": "0.28.1"
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.21", "version": "1.1.24",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+242 -15
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, RefreshCw, X } from 'lucide-react' import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api' import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, Template } from '../services/api'
import { useDialog } from './Dialog' import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext' import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth' import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
@@ -33,6 +33,11 @@ const defaultForm: CreateContainerRequest = {
extra_ports: [], extra_ports: [],
port_mapping_count: 2, port_mapping_count: 2,
assign_nat: true, assign_nat: true,
lan_ipv4_mode: '',
lan_interface: '',
lan_ipv4_address: '',
lan_ipv4_prefix_len: 24,
lan_ipv4_gateway: '',
snapshot_limit: 1, snapshot_limit: 1,
assign_ipv4: false, assign_ipv4: false,
ipv4_count: 1, ipv4_count: 1,
@@ -43,6 +48,8 @@ const defaultForm: CreateContainerRequest = {
ssh_auth_mode: 'auto_password', ssh_auth_mode: 'auto_password',
ssh_password: '', ssh_password: '',
ssh_public_key: '', ssh_public_key: '',
allowed_image_ids: [],
image_limit_configured: false,
expires_at: '', expires_at: '',
} }
@@ -55,6 +62,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const [batchCount, setBatchCount] = useState(1) const [batchCount, setBatchCount] = useState(1)
const [form, setForm] = useState<CreateContainerRequest>(defaultForm) const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null) const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [hostReport, setHostReport] = useState<HostProbeReport | null>(null)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null) const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('') const [nameError, setNameError] = useState('')
@@ -67,7 +75,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
setTemplates(data) setTemplates(data)
setForm((prev) => { setForm((prev) => {
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') 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) .catch(console.error)
@@ -88,6 +103,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
getHostInfo() getHostInfo()
.then((res) => setHostInfo(res.data.data || null)) .then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null)) .catch(() => setHostInfo(null))
getHostReport()
.then((res) => setHostReport(res.data.data || null))
.catch(() => setHostReport(null))
}, [isOpen, form.virtualization]) }, [isOpen, form.virtualization])
const ipv6Available = !!ipv6Status?.available const ipv6Available = !!ipv6Status?.available
@@ -98,9 +117,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const manualIPv4s = form.public_ipv4s || [] const manualIPv4s = form.public_ipv4s || []
const maxVCPU = hostInfo?.cpu.cores || 64 const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
useEffect(() => {
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))
}
}, [hostInfo, kvmAvailable, form.virtualization])
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB) const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const natEnabled = form.assign_nat !== false 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 natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0 const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
const linuxTemplate = !isWindowsTemplate(form.template_id) const linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
@@ -153,11 +183,18 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return 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('提示', '请勾选任意一个可用网络') dialog.alert('提示', '请勾选任意一个可用网络')
return 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
}
}
const authError = validateSSHAuthInputs(form) const authError = validateSSHAuthInputs(form)
if (authError) { if (authError) {
dialog.alert('登录方式有误', authError) dialog.alert('登录方式有误', authError)
@@ -190,7 +227,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
await onSuccess(containers) await onSuccess(containers)
onClose() onClose()
setBatchCount(1) 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) { } catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } } const error = err as { response?: { data?: { message?: string } } }
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试') dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
@@ -234,15 +271,21 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))} onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_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'}`} 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 LXC
</button> </button>
<button <button
type="button" type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))} disabled={!kvmAvailable}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`} title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
onClick={() => {
if (kvmAvailable) {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_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'}`}
> >
KVM KVM
</button> </button>
@@ -257,7 +300,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
) : ( ) : (
<select <select
value={form.template_id} 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} className={inputClass}
> >
{templates.map((template) => ( {templates.map((template) => (
@@ -270,6 +318,38 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field> </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 && ( {linuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm"> <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> <div className="mb-2 font-medium text-gray-800"></div>
@@ -329,7 +409,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
...form, ...form,
assign_ipv4: event.target.checked, assign_ipv4: event.target.checked,
public_ipv4s: event.target.checked ? form.public_ipv4s : [], 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" className="mt-1"
/> />
@@ -395,6 +475,98 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
)} )}
</div> </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={`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"> <div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3"> <label className="flex min-w-0 flex-1 items-start gap-3">
@@ -408,7 +580,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<span className="min-w-0"> <span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span> <span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
<span className="block text-xs text-gray-500 truncate"> <span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)} {ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)}
</span> </span>
</span> </span>
</label> </label>
@@ -438,7 +610,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
assign_nat: checked, assign_nat: checked,
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0, port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
extra_ports: [], 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" className="mt-1"
@@ -698,10 +870,13 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest { function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
const normalized = applyTemplateDefaults(form) 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 wantsIPv4 = !!normalized.assign_ipv4
const wantsIPv6 = !!normalized.assign_ipv6 const wantsIPv6 = !!normalized.assign_ipv6
// IPv4 and NAT are mutually exclusive // IPv4 and NAT are mutually exclusive
const wantsNAT = wantsIPv4 ? false : normalized.assign_nat !== false const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
const linuxTemplate = !isWindowsTemplate(normalized.template_id) const linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password' const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
return { return {
@@ -711,6 +886,11 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
disk_gb: Math.round(normalized.disk_gb), disk_gb: Math.round(normalized.disk_gb),
assign_nat: wantsNAT, assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0, port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
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, assign_ipv4: wantsIPv4,
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0, ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [], public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
@@ -733,6 +913,16 @@ function validateSSHAuthInputs(form: CreateContainerRequest) {
return '' 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 { function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
if (!isWindowsTemplate(form.template_id)) return form if (!isWindowsTemplate(form.template_id)) return form
return { return {
@@ -758,11 +948,33 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
return Math.min(Math.max(next, min), max ?? next) return Math.min(Math.max(next, min), max ?? next)
} }
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 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 = { const createNetworkText = {
zh: { zh: {
publicIPv4: '公网 IPv4', publicIPv4: '公网 IPv4',
noAllocatableIPv4: '未检测到可分配公网 IPv4', noAllocatableIPv4: '未检测到可分配公网 IPv4',
publicIPv6: '公网 IPv6', publicIPv6: '可分配 IPv6 前缀',
use: '使用', use: '使用',
checkingIPv6Prefix: '正在检测 IPv6 前缀...', checkingIPv6Prefix: '正在检测 IPv6 前缀...',
publicNAT: '公网 NAT', publicNAT: '公网 NAT',
@@ -771,7 +983,7 @@ const createNetworkText = {
en: { en: {
publicIPv4: 'Public IPv4', publicIPv4: 'Public IPv4',
noAllocatableIPv4: 'No allocatable public IPv4 detected', noAllocatableIPv4: 'No allocatable public IPv4 detected',
publicIPv6: 'Public IPv6', publicIPv6: 'Allocatable IPv6 Prefix',
use: 'Use', use: 'Use',
checkingIPv6Prefix: 'Checking IPv6 prefix...', checkingIPv6Prefix: 'Checking IPv6 prefix...',
publicNAT: 'Public NAT', publicNAT: 'Public NAT',
@@ -779,6 +991,21 @@ const createNetworkText = {
}, },
} as const } as const
function formatIPv6StatusReason(reason: string | undefined, language: Language, fallback: string) {
if (!reason) return fallback
if (reason.includes('/128 single-address IPv6 is not assignable')) {
return language === 'en'
? 'No allocatable IPv6 prefix. The host only has a /128 single IPv6 address.'
: '未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。'
}
if (reason.includes('outbound IPv6 connectivity test failed')) {
return language === 'en'
? reason
: '宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。'
}
return reason
}
function formatAllocatableIPv4Count(count: number, language: Language) { function formatAllocatableIPv4Count(count: number, language: Language) {
return language === 'en' return language === 'en'
? `${count} allocatable address${count === 1 ? '' : 'es'} detected` ? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
+40 -37
View File
@@ -32,6 +32,7 @@ import {
assignIPv6, assignIPv6,
APIResponse, APIResponse,
Container, Container,
ContainerMetricPoint as ContainerMetricSample,
ContainerUsage, ContainerUsage,
createSubUser, createSubUser,
createContainerSnapshot, createContainerSnapshot,
@@ -39,6 +40,7 @@ import {
deleteContainerSnapshot, deleteContainerSnapshot,
deletePortMapping, deletePortMapping,
getContainer, getContainer,
getContainerHistory,
getContainerSnapshots, getContainerSnapshots,
getContainerUsage, getContainerUsage,
getHostInfo, getHostInfo,
@@ -209,39 +211,19 @@ export default function ContainerDetail() {
} }
}, [containerIdentifier, container?.snapshot_limit]) }, [containerIdentifier, container?.snapshot_limit])
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => { const fetchMetricHistory = useCallback(async () => {
if (!containerIdentifier || !currentContainer) return if (!containerIdentifier) return
try {
const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0 const res = await getContainerHistory(containerIdentifier)
? nextUsage.memory_total_bytes const points = (res.data.data || []).map(normalizeContainerMetricSample)
: currentContainer.ram_mb * 1024 * 1024 if (points.length > 0) {
const memoryPct = memoryTotalBytes > 0 setHistory(points)
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100 localStorage.setItem(historyKey(container?.uuid || containerIdentifier), JSON.stringify(points))
: 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,
} }
} catch (err) {
setHistory((prev) => { console.error('Failed to fetch metric history:', err)
const cutoff = Date.now() - statsRanges['1w'] }
const next = [...prev.filter((item) => item.ts >= cutoff), point] }, [containerIdentifier, container?.uuid])
localStorage.setItem(historyKey(currentContainer.uuid || containerIdentifier), JSON.stringify(next))
return next
})
}, [containerIdentifier])
const fetchUsage = useCallback(async () => { const fetchUsage = useCallback(async () => {
if (!containerIdentifier) return if (!containerIdentifier) return
@@ -249,12 +231,11 @@ export default function ContainerDetail() {
const res = await getContainerUsage(containerIdentifier) const res = await getContainerUsage(containerIdentifier)
if (res.data.data) { if (res.data.data) {
setUsage(res.data.data) setUsage(res.data.data)
appendUsagePoint(res.data.data, container)
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch usage:', err) console.error('Failed to fetch usage:', err)
} }
}, [containerIdentifier, container, appendUsagePoint]) }, [containerIdentifier])
useEffect(() => { useEffect(() => {
if (!containerIdentifier) return if (!containerIdentifier) return
@@ -296,6 +277,12 @@ export default function ContainerDetail() {
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [fetchUsage]) }, [fetchUsage])
useEffect(() => {
fetchMetricHistory()
const timer = window.setInterval(fetchMetricHistory, 30000)
return () => window.clearInterval(timer)
}, [fetchMetricHistory])
useEffect(() => { useEffect(() => {
if (showSnapshots) fetchSnapshots() if (showSnapshots) fetchSnapshots()
}, [showSnapshots, fetchSnapshots]) }, [showSnapshots, fetchSnapshots])
@@ -523,10 +510,12 @@ export default function ContainerDetail() {
const openReinstall = async () => { const openReinstall = async () => {
try { try {
const res = await getEnabledImages(container?.virtualization || 'lxc') const res = await getEnabledImages(container?.virtualization || 'lxc', containerIdentifier)
if (res.data.data) { if (res.data.data) {
setTemplates(res.data.data) const data = res.data.data
setSelectedTemplate(res.data.data[0]?.id || '') setTemplates(data)
const currentTemplate = container?.template || ''
setSelectedTemplate(data.some((template) => template.id === currentTemplate) ? currentTemplate : (data[0]?.id || ''))
} }
setReinstallAuthMode('keep') setReinstallAuthMode('keep')
setReinstallPasswordDraft('') setReinstallPasswordDraft('')
@@ -2490,6 +2479,20 @@ 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 historyKey(containerName: string) { function historyKey(containerName: string) {
return `clicd_container_metric_history:${containerName}` return `clicd_container_metric_history:${containerName}`
} }
+4 -3
View File
@@ -395,10 +395,8 @@ export default function Containers() {
const isPlaceholder = !!container.isPlaceholder const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name] const usage = usageByName[container.name]
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const cpuPct = isRunning const cpuPct = isRunning
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1)) ? clamp((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))
: 0 : 0
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0 const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
? usage.memory_total_bytes ? usage.memory_total_bytes
@@ -969,6 +967,7 @@ function getTemplateName(id: string) {
const map: Record<string, string> = { const map: Record<string, string> = {
'ubuntu-noble': 'Ubuntu 24.04', 'ubuntu-noble': 'Ubuntu 24.04',
'ubuntu-jammy': 'Ubuntu 22.04', 'ubuntu-jammy': 'Ubuntu 22.04',
'debian-trixie': 'Debian 13',
'debian-bookworm': 'Debian 12', 'debian-bookworm': 'Debian 12',
'debian-bullseye': 'Debian 11', 'debian-bullseye': 'Debian 11',
'alpine-3.21': 'Alpine 3.21', 'alpine-3.21': 'Alpine 3.21',
@@ -978,6 +977,8 @@ function getTemplateName(id: string) {
'rockylinux-10': 'Rocky 10', 'rockylinux-10': 'Rocky 10',
'kvm-ubuntu-noble': 'Ubuntu 24.04', 'kvm-ubuntu-noble': 'Ubuntu 24.04',
'kvm-ubuntu-jammy': 'Ubuntu 22.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-bookworm': 'Debian 12',
'kvm-debian-bullseye': 'Debian 11', 'kvm-debian-bullseye': 'Debian 11',
'kvm-rockylinux-9': 'Rocky 9', 'kvm-rockylinux-9': 'Rocky 9',
+35 -29
View File
@@ -7,7 +7,7 @@ import ResourceStatsPanel, {
StatsRangeKey, StatsRangeKey,
statsRanges, statsRanges,
} from '../components/ResourceStatsPanel' } 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 = { type HostMetricPoint = {
ts: number ts: number
@@ -30,6 +30,19 @@ export default function Dashboard() {
const [range, setRange] = useState<StatsRangeKey>('30m') const [range, setRange] = useState<StatsRangeKey>('30m')
const [loading, setLoading] = useState(true) 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 () => { const fetchData = useCallback(async () => {
try { try {
const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()]) const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()])
@@ -37,7 +50,6 @@ export default function Dashboard() {
if (hostRes.data.data) { if (hostRes.data.data) {
const nextHost = hostRes.data.data const nextHost = hostRes.data.data
setHost(nextHost) setHost(nextHost)
appendHostPoint(nextHost, setHistory)
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -47,10 +59,15 @@ export default function Dashboard() {
}, []) }, [])
useEffect(() => { useEffect(() => {
fetchHistory()
fetchData() fetchData()
const interval = window.setInterval(fetchData, 5000) const interval = window.setInterval(fetchData, 5000)
return () => window.clearInterval(interval) const historyInterval = window.setInterval(fetchHistory, 30000)
}, [fetchData]) return () => {
window.clearInterval(interval)
window.clearInterval(historyInterval)
}
}, [fetchData, fetchHistory])
if (loading) { if (loading) {
return ( 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[] { function readHostHistory(): HostMetricPoint[] {
try { try {
const raw = localStorage.getItem(hostHistoryKey) 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) { function clamp(value: number) {
if (!Number.isFinite(value)) return 0 if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(value, 100)) return Math.max(0, Math.min(value, 100))
+3 -2
View File
@@ -205,7 +205,7 @@ const hostReportText = {
ipv4Address: 'IPv4 地址', ipv4Address: 'IPv4 地址',
ipv4Prefix: 'IPv4 段', ipv4Prefix: 'IPv4 段',
ipv6Address: 'IPv6 地址', ipv6Address: 'IPv6 地址',
ipv6Prefix: 'IPv6 ', ipv6Prefix: '可分配 IPv6 前缀',
gateway: '网关', gateway: '网关',
memoryModules: '内存条', memoryModules: '内存条',
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限', noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
@@ -277,7 +277,7 @@ const hostReportText = {
ipv4Address: 'IPv4 Addresses', ipv4Address: 'IPv4 Addresses',
ipv4Prefix: 'IPv4 Prefixes', ipv4Prefix: 'IPv4 Prefixes',
ipv6Address: 'IPv6 Addresses', ipv6Address: 'IPv6 Addresses',
ipv6Prefix: 'IPv6 Prefixes', ipv6Prefix: 'Allocatable IPv6 Prefixes',
gateway: 'Gateway', gateway: 'Gateway',
memoryModules: 'Memory Modules', memoryModules: 'Memory Modules',
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.', noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
@@ -511,6 +511,7 @@ function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boole
} }
function gpuTypeLabel(value: string, language: Language) { function gpuTypeLabel(value: string, language: Language) {
if (value === 'virtual') return language === 'en' ? 'Virtual' : '虚拟'
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显' if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显' if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
return value || '-' return value || '-'
+2
View File
@@ -148,6 +148,7 @@ export default function ImageManagement() {
onToggle={handleToggle} onToggle={handleToggle}
/> />
{kvmImages.length > 0 && (
<ImageTable <ImageTable
title="KVM 虚拟机镜像" title="KVM 虚拟机镜像"
images={kvmImages} images={kvmImages}
@@ -159,6 +160,7 @@ export default function ImageManagement() {
onDelete={handleDelete} onDelete={handleDelete}
onToggle={handleToggle} onToggle={handleToggle}
/> />
)}
</div> </div>
) )
} }
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.21</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.24</p>
</div> </div>
</div> </div>
) )
+54 -1
View File
@@ -9,6 +9,7 @@ import {
updateRoutingPools, updateRoutingPools,
type IPv4Route, type IPv4Route,
type IPv6Route, type IPv6Route,
type LANDHCPRoute,
type IPv6PrefixInfo, type IPv6PrefixInfo,
type NAT4PortRange, type NAT4PortRange,
type NAT4Route, type NAT4Route,
@@ -56,6 +57,7 @@ export default function Routing() {
const publicIPv4s = routing?.public_ipv4_addresses || [] const publicIPv4s = routing?.public_ipv4_addresses || []
const ipv4Assignments = routing?.ipv4_assignments || [] const ipv4Assignments = routing?.ipv4_assignments || []
const lanDHCPAssignments = routing?.lan_dhcp_assignments || []
const nat4Mappings = routing?.nat4_mappings || [] const nat4Mappings = routing?.nat4_mappings || []
const ipv6Prefixes = routing?.ipv6_prefixes || [] const ipv6Prefixes = routing?.ipv6_prefixes || []
const ipv6Assignments = routing?.ipv6_assignments || [] const ipv6Assignments = routing?.ipv6_assignments || []
@@ -276,7 +278,7 @@ export default function Routing() {
</button> </button>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-4">
<CapacityCard <CapacityCard
title={text.nat4Ports} title={text.nat4Ports}
watermark="NAT4" 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.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} /> <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> </div>
@@ -541,6 +544,48 @@ export default function Routing() {
</RouteModal> </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} />}> <Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
{nat4Mappings.length === 0 ? ( {nat4Mappings.length === 0 ? (
<EmptyState text={text.noIPv4NATMappings} icon={<Network className="h-7 w-7" />} /> <EmptyState text={text.noIPv4NATMappings} icon={<Network className="h-7 w-7" />} />
@@ -851,6 +896,10 @@ const routingText = {
saveNAT4RangeFailed: '保存 NAT4 范围失败', saveNAT4RangeFailed: '保存 NAT4 范围失败',
remainingTotal: '剩余 / 总数', remainingTotal: '剩余 / 总数',
publicIPv4: '公网 IPv4', publicIPv4: '公网 IPv4',
lanDHCP: '局域网 DHCP',
dhcpManagedByLAN: '由局域网 DHCP 分配',
lanDHCPAssignments: '局域网 DHCP 分配',
noLANDHCPAssignments: '暂无局域网 DHCP 分配',
publicIPv4Pool: '公网 IPv4 池', publicIPv4Pool: '公网 IPv4 池',
editPool: '编辑 IP 池', editPool: '编辑 IP 池',
noPublicIPv4Pool: '暂未配置公网 IPv4 池', noPublicIPv4Pool: '暂未配置公网 IPv4 池',
@@ -922,6 +971,10 @@ const routingText = {
saveNAT4RangeFailed: 'Save NAT4 range failed', saveNAT4RangeFailed: 'Save NAT4 range failed',
remainingTotal: 'remaining / total', remainingTotal: 'remaining / total',
publicIPv4: 'Public IPv4', publicIPv4: 'Public IPv4',
lanDHCP: 'LAN DHCP',
dhcpManagedByLAN: 'Managed by LAN DHCP',
lanDHCPAssignments: 'LAN DHCP assignments',
noLANDHCPAssignments: 'No LAN DHCP assignments',
publicIPv4Pool: 'Public IPv4 pool', publicIPv4Pool: 'Public IPv4 pool',
editPool: 'Edit pool', editPool: 'Edit pool',
noPublicIPv4Pool: 'No public IPv4 pool configured', noPublicIPv4Pool: 'No public IPv4 pool configured',
+127 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react' 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 { useDialog } from '../components/Dialog'
import api, { AuditLog, LoginLog } from '../services/api' import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
import { copyToClipboard } from '../utils/clipboard' import { copyToClipboard } from '../utils/clipboard'
interface SubUserItem { interface SubUserItem {
@@ -9,6 +9,9 @@ interface SubUserItem {
username: string username: string
container_names: string[] container_names: string[]
container_uuids: string[] container_uuids: string[]
allowed_image_ids?: string[]
image_limit_configured?: boolean
current_image_ids?: string[]
container_name: string container_name: string
container_uuid: string container_uuid: string
access_code: string access_code: string
@@ -34,6 +37,11 @@ export default function SubUserManagement() {
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null) const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
const [modalTitle, setModalTitle] = useState('') const [modalTitle, setModalTitle] = useState('')
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null) 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 [rotatingPassword, setRotatingPassword] = useState(false)
const [logPage, setLogPage] = useState(1) const [logPage, setLogPage] = useState(1)
const [logPageSize, setLogPageSize] = useState(10) const [logPageSize, setLogPageSize] = useState(10)
@@ -78,6 +86,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) => { const showAuditLogs = async (user: SubUserItem) => {
try { try {
const res = await api.get(`/sub-users/${user.id}/audit-logs`) const res = await api.get(`/sub-users/${user.id}/audit-logs`)
@@ -190,6 +238,14 @@ export default function SubUserManagement() {
<LogIn className="w-3.5 h-3.5" /> <LogIn className="w-3.5 h-3.5" />
</button> </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> </div>
</td> </td>
</tr> </tr>
@@ -253,6 +309,75 @@ export default function SubUserManagement() {
</div> </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 */} {/* Log Modal */}
{(auditLogs || loginLogs) && ( {(auditLogs || loginLogs) && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
+75 -2
View File
@@ -94,6 +94,12 @@ export interface Container {
io_write_mbps: number io_write_mbps: number
status: string status: string
ip: 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[] public_ipv4s?: PublicIPv4Assignment[]
ipv6: string ipv6: string
ipv6_prefix_len: number ipv6_prefix_len: number
@@ -154,6 +160,11 @@ export interface CreateContainerRequest {
extra_ports: number[] extra_ports: number[]
port_mapping_count: number port_mapping_count: number
assign_nat?: boolean 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 snapshot_limit: number
assign_ipv4?: boolean assign_ipv4?: boolean
ipv4_count?: number ipv4_count?: number
@@ -164,6 +175,8 @@ export interface CreateContainerRequest {
ssh_auth_mode?: string ssh_auth_mode?: string
ssh_password?: string ssh_password?: string
ssh_public_key?: string ssh_public_key?: string
allowed_image_ids?: string[]
image_limit_configured?: boolean
expires_at: string expires_at: string
} }
@@ -235,6 +248,27 @@ export interface HostInfo {
} }
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number } disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
load: { load1: number; load5: number; load15: number } load: { load1: number; load5: number; load15: number }
runtime?: {
lxc_available: boolean
kvm_available: boolean
dev_kvm: boolean
nested_virtualization: boolean
nested_detail: string
support_mode: 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 { export interface HostProbeReport {
@@ -345,6 +379,18 @@ export interface ContainerUsage {
guest_metrics?: boolean 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> { export interface APIResponse<T = unknown> {
success: boolean success: boolean
message?: string message?: string
@@ -467,6 +513,9 @@ export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
export const getContainerUsage = (id: ContainerIdentifier) => export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`) api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
export const getContainerHistory = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerMetricPoint[]>>(`/containers/${id}/history`)
export interface TrafficInfo { export interface TrafficInfo {
total_used_bytes: number total_used_bytes: number
rx_used_bytes: number rx_used_bytes: number
@@ -564,6 +613,19 @@ export interface IPv4Route {
gateway?: string 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 { export interface IPv6Route {
container_id: number container_id: number
container_name: string container_name: string
@@ -578,10 +640,12 @@ export interface RoutingInfo {
nat4: RouteCapacity nat4: RouteCapacity
nat4_port_range: NAT4PortRange nat4_port_range: NAT4PortRange
ipv4: RouteCapacity ipv4: RouteCapacity
lan_dhcp: RouteCapacity
ipv6: RouteCapacity ipv6: RouteCapacity
host_public_ipv4?: PublicIPv4Info host_public_ipv4?: PublicIPv4Info
public_ipv4_addresses: PublicIPv4Info[] public_ipv4_addresses: PublicIPv4Info[]
ipv4_assignments: IPv4Route[] ipv4_assignments: IPv4Route[]
lan_dhcp_assignments: LANDHCPRoute[]
nat4_mappings: NAT4Route[] nat4_mappings: NAT4Route[]
ipv6_assignments: IPv6Route[] ipv6_assignments: IPv6Route[]
ipv6_prefixes: IPv6PrefixInfo[] ipv6_prefixes: IPv6PrefixInfo[]
@@ -649,8 +713,8 @@ export const deleteImage = (templateId: string) =>
export const toggleImage = (templateId: string, enabled: boolean) => export const toggleImage = (templateId: string, enabled: boolean) =>
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled }) api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
export const getEnabledImages = (virtualization = 'lxc') => export const getEnabledImages = (virtualization = 'lxc', container?: ContainerIdentifier) =>
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } }) api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization, ...(container ? { container: String(container) } : {}) } })
// Dashboard // Dashboard
export const getDashboard = () => export const getDashboard = () =>
@@ -659,6 +723,9 @@ export const getDashboard = () =>
export const getHostInfo = () => export const getHostInfo = () =>
api.get<APIResponse<HostInfo>>('/host-info') api.get<APIResponse<HostInfo>>('/host-info')
export const getHostHistory = () =>
api.get<APIResponse<HostMetricPoint[]>>('/host-history')
export const getHostReport = () => export const getHostReport = () =>
api.get<APIResponse<HostProbeReport>>('/host-report') api.get<APIResponse<HostProbeReport>>('/host-report')
@@ -763,6 +830,9 @@ export interface SubUser {
password?: string password?: string
container_names: string[] container_names: string[]
container_uuids?: string[] container_uuids?: string[]
allowed_image_ids?: string[]
image_limit_configured?: boolean
current_image_ids?: string[]
access_code: string access_code: string
created_at: string created_at: string
} }
@@ -770,6 +840,9 @@ export interface SubUser {
export const createSubUser = (containerId: ContainerIdentifier) => export const createSubUser = (containerId: ContainerIdentifier) =>
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) }) 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 // Audit Logs
export interface AuditLog { export interface AuditLog {
time: string time: string
+155 -31
View File
@@ -3,7 +3,6 @@ set -eu
REPO="${CLICD_REPO:-MengMengCode/CLICD}" REPO="${CLICD_REPO:-MengMengCode/CLICD}"
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}" CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
ASSET="clicd-linux-amd64.tar.gz"
ACTION="${1:-install}" ACTION="${1:-install}"
ACTION_CONFIRM="${2:-}" ACTION_CONFIRM="${2:-}"
ISSUE_URL="https://github.com/${REPO}/issues" ISSUE_URL="https://github.com/${REPO}/issues"
@@ -11,6 +10,80 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}" INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created" LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
normalize_clicd_arch() {
arch="$1"
case "$(printf '%s' "$arch" | tr 'A-Z' 'a-z')" in
x86_64|amd64) echo amd64 ;;
aarch64|arm64) echo arm64 ;;
*) echo "" ;;
esac
}
HOST_ARCH_RAW="$(uname -m 2>/dev/null || echo unknown)"
CLICD_ARCH_NORMALIZED="$(normalize_clicd_arch "${CLICD_ARCH:-$HOST_ARCH_RAW}")"
ASSET_DIR="clicd-linux-${CLICD_ARCH_NORMALIZED:-unknown}"
ASSET="${ASSET_DIR}.tar.gz"
BINARY_ASSET="$ASSET_DIR"
kvm_supported_arch() {
[ "$CLICD_ARCH_NORMALIZED" = "amd64" ] || [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]
}
warn_kvm_unsupported_arch() {
if ! kvm_supported_arch; then
warn "当前架构 ${CLICD_ARCH_NORMALIZED:-unknown} 已适配 CLICD/LXCKVM 功能当前支持 x86_64/amd64 和 aarch64/arm64,将跳过 KVM 专用依赖。"
fi
}
qemu_system_package_apk() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-system-x86_64 ;;
esac
}
qemu_system_package_apt() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-arm ;;
*) echo qemu-system-x86 ;;
esac
}
qemu_system_package_rpm() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-kvm ;;
esac
}
qemu_emulator_cmd() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-system-x86_64 ;;
esac
}
qemu_efi_package_apt() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-efi-aarch64 ;;
*) echo ovmf ;;
esac
}
qemu_efi_package_apk() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo edk2-aarch64 ;;
*) echo ovmf ;;
esac
}
qemu_efi_package_rpm() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo edk2-aarch64 ;;
*) echo edk2-ovmf ;;
esac
}
normalize_lang() { normalize_lang() {
lang="$1" lang="$1"
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
@@ -286,14 +359,8 @@ run_step() {
} }
check_os_compatibility() { check_os_compatibility() {
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)" log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=${HOST_ARCH_RAW} CLICD_ARCH=${CLICD_ARCH_NORMALIZED:-unsupported}"
case "$(uname -m 2>/dev/null || echo unknown)" in [ -n "$CLICD_ARCH_NORMALIZED" ] || die "当前安装包支持 x86_64/amd64 和 aarch64/arm64,当前架构:${HOST_ARCH_RAW}"
x86_64|amd64)
;;
*)
die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)"
;;
esac
if ! is_systemd && ! is_openrc; then if ! is_systemd && ! is_openrc; then
die "未检测到 systemd 或 OpenRC,无法安装服务。" die "未检测到 systemd 或 OpenRC,无法安装服务。"
fi fi
@@ -470,13 +537,24 @@ remove_clicd_lxc_image_cache() {
for image in \ for image in \
"ubuntu noble amd64" \ "ubuntu noble amd64" \
"ubuntu jammy amd64" \ "ubuntu jammy amd64" \
"debian trixie amd64" \
"debian bookworm amd64" \ "debian bookworm amd64" \
"debian bullseye amd64" \ "debian bullseye amd64" \
"alpine 3.21 amd64" \ "alpine 3.21 amd64" \
"centos 9-Stream amd64" \ "centos 9-Stream amd64" \
"archlinux current amd64" \ "archlinux current amd64" \
"fedora 44 amd64" \ "fedora 44 amd64" \
"rockylinux 10 amd64" "rockylinux 10 amd64" \
"ubuntu noble arm64" \
"ubuntu jammy arm64" \
"debian trixie arm64" \
"debian bookworm arm64" \
"debian bullseye arm64" \
"alpine 3.21 arm64" \
"centos 9-Stream arm64" \
"archlinux current arm64" \
"fedora 44 arm64" \
"rockylinux 10 arm64"
do do
set -- $image set -- $image
distro="$1" distro="$1"
@@ -950,15 +1028,21 @@ install_apk() {
iproute2 \ iproute2 \
iptables \ iptables \
dnsmasq \ dnsmasq \
dbus \ dbus
qemu-system-x86_64 \
if kvm_supported_arch; then
apk add --no-cache \
"$(qemu_system_package_apk)" \
qemu-img \ qemu-img \
libvirt \ libvirt \
libvirt-daemon \ libvirt-daemon \
libvirt-client \ libvirt-client \
libvirt-qemu libvirt-qemu
else
warn_kvm_unsupported_arch
fi
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools; do for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools "$(qemu_efi_package_apk)"; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
@@ -985,9 +1069,12 @@ install_apt() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq-base \ dnsmasq-base
qemu-kvm \
qemu-system-x86 \ if kvm_supported_arch; then
if [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]; then
apt-get install -y \
"$(qemu_system_package_apt)" \
qemu-utils \ qemu-utils \
libvirt-daemon-system \ libvirt-daemon-system \
libvirt-clients \ libvirt-clients \
@@ -996,7 +1083,25 @@ install_apt() {
xorriso \ xorriso \
smartmontools \ smartmontools \
virtinst \ virtinst \
ovmf "$(qemu_efi_package_apt)"
else
apt-get install -y \
qemu-kvm \
"$(qemu_system_package_apt)" \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
smartmontools \
virtinst \
"$(qemu_efi_package_apt)"
fi
else
warn_kvm_unsupported_arch
apt-get install -y qemu-utils genisoimage xorriso smartmontools >/dev/null 2>&1 || true
fi
} }
enable_el_repos() { enable_el_repos() {
@@ -1032,8 +1137,11 @@ install_dnf() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq \ dnsmasq
qemu-kvm \
if kvm_supported_arch; then
dnf install -y \
"$(qemu_system_package_rpm)" \
qemu-img \ qemu-img \
libvirt \ libvirt \
libvirt-daemon-kvm \ libvirt-daemon-kvm \
@@ -1041,8 +1149,12 @@ install_dnf() {
virt-install \ virt-install \
cloud-utils \ cloud-utils \
genisoimage genisoimage
else
warn_kvm_unsupported_arch
dnf install -y qemu-img genisoimage >/dev/null 2>&1 || true
fi
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
@@ -1067,8 +1179,11 @@ install_yum() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq \ dnsmasq
qemu-kvm \
if kvm_supported_arch; then
yum install -y \
"$(qemu_system_package_rpm)" \
qemu-img \ qemu-img \
libvirt \ libvirt \
libvirt-daemon-kvm \ libvirt-daemon-kvm \
@@ -1076,8 +1191,12 @@ install_yum() {
virt-install \ virt-install \
cloud-utils \ cloud-utils \
genisoimage genisoimage
else
warn_kvm_unsupported_arch
yum install -y qemu-img genisoimage >/dev/null 2>&1 || true
fi
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
@@ -1117,7 +1236,9 @@ install_dependencies() {
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。" has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。" has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。" has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
if kvm_supported_arch; then
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。" has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
has_cmd "$(qemu_emulator_cmd)" || die "依赖安装后仍未找到 $(qemu_emulator_cmd),请检查 QEMU 安装。"
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。" has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。" has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
@@ -1126,6 +1247,9 @@ install_dependencies() {
if [ ! -e /dev/kvm ]; then if [ ! -e /dev/kvm ]; then
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。" warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
fi fi
else
warn_kvm_unsupported_arch
fi
} }
configure_kernel_networking() { configure_kernel_networking() {
@@ -1384,7 +1508,7 @@ download_release_if_needed() {
if [ "$archive_ok" = "1" ]; then if [ "$archive_ok" = "1" ]; then
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path" tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
else else
binary_asset="clicd-linux-amd64" binary_asset="$BINARY_ASSET"
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}" binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
else else
@@ -1402,9 +1526,9 @@ download_release_if_needed() {
[ -n "$url" ] || continue [ -n "$url" ] || continue
log "Trying release binary: $url" log "Trying release binary: $url"
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
mkdir -p "$tmp_dir/clicd-linux-amd64" mkdir -p "$tmp_dir/$ASSET_DIR"
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd" cp "$binary_path" "$tmp_dir/$ASSET_DIR/clicd"
chmod +x "$tmp_dir/clicd-linux-amd64/clicd" chmod +x "$tmp_dir/$ASSET_DIR/clicd"
binary_ok=1 binary_ok=1
break break
fi fi
@@ -1414,8 +1538,8 @@ download_release_if_needed() {
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url" [ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
fi fi
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory" [ -d "$tmp_dir/$ASSET_DIR" ] || die "Release package layout is invalid: missing $ASSET_DIR directory"
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。" [ -f "$tmp_dir/$ASSET_DIR/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
} }
install_binary() { install_binary() {
@@ -1430,8 +1554,8 @@ install_binary() {
download_dir="" download_dir=""
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)" download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then if [ -n "$download_dir" ] && [ -f "$download_dir/$ASSET_DIR/clicd" ]; then
bin_src="$download_dir/clicd-linux-amd64/clicd" bin_src="$download_dir/$ASSET_DIR/clicd"
fi fi
fi fi
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。" [ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"