mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 06:14:42 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d05ca8cc4c | |||
| 48fa14f8a7 | |||
| 5c6d6eafc9 | |||
| 2ecdb5c26f | |||
| ae02241370 | |||
| fdd83977fc |
@@ -71,3 +71,4 @@ deploy.ps1
|
|||||||
backend/clicd
|
backend/clicd
|
||||||
api.md
|
api.md
|
||||||
deploy-arm.ps1
|
deploy-arm.ps1
|
||||||
|
deploy-dhcp.ps1
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -655,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) {
|
||||||
|
|||||||
+141
-16
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -22,12 +23,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HostInfo struct {
|
type HostInfo struct {
|
||||||
CPU CpuInfo `json:"cpu"`
|
CPU CpuInfo `json:"cpu"`
|
||||||
RAM MemoryInfo `json:"ram"`
|
RAM MemoryInfo `json:"ram"`
|
||||||
Disk DiskInfo `json:"disk"`
|
Disk DiskInfo `json:"disk"`
|
||||||
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 {
|
||||||
@@ -216,14 +218,35 @@ 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 egressIPv4Mu sync.Mutex
|
||||||
var cachedEgressIPv4 lxc.PublicIPInfo
|
var cachedEgressIPv4 lxc.PublicIPInfo
|
||||||
var cachedEgressIPv4At time.Time
|
var cachedEgressIPv4At time.Time
|
||||||
|
|
||||||
|
const (
|
||||||
|
hostMetricSampleInterval = 30 * time.Second
|
||||||
|
hostMetricRetention = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
type cpuTimes struct {
|
type cpuTimes struct {
|
||||||
Total uint64
|
Total uint64
|
||||||
Idle uint64
|
Idle uint64
|
||||||
@@ -238,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()},
|
||||||
}
|
}
|
||||||
@@ -245,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 {
|
||||||
@@ -395,20 +518,22 @@ 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 := detectDisplayPublicIPv4()
|
if includeDetails {
|
||||||
network.PublicIPv4 = publicIPv4.Address
|
publicIPv4 := detectDisplayPublicIPv4()
|
||||||
network.PublicIPv4Interface = publicIPv4.Interface
|
network.PublicIPv4 = publicIPv4.Address
|
||||||
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
network.PublicIPv4Interface = publicIPv4.Interface
|
||||||
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
||||||
if len(network.IPv6Prefixes) > 0 {
|
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
||||||
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
if len(network.IPv6Prefixes) > 0 {
|
||||||
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
||||||
|
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
||||||
|
}
|
||||||
}
|
}
|
||||||
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
||||||
|
|
||||||
|
|||||||
@@ -541,6 +541,24 @@ 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 {
|
||||||
@@ -549,7 +567,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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,
|
||||||
@@ -558,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,
|
||||||
@@ -574,6 +598,42 @@ 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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+232
-43
@@ -22,24 +22,30 @@ func generateRandomStr(length int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type subUserResponse struct {
|
type subUserResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
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"`
|
||||||
AccessCode string `json:"access_code"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
|
CurrentImageIDs []string `json:"current_image_ids,omitempty"`
|
||||||
|
AccessCode string `json:"access_code"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
||||||
return subUserResponse{
|
return subUserResponse{
|
||||||
ID: su.ID,
|
ID: su.ID,
|
||||||
Username: su.Username,
|
Username: su.Username,
|
||||||
Password: password,
|
Password: password,
|
||||||
ContainerNames: su.ContainerNames,
|
ContainerNames: su.ContainerNames,
|
||||||
ContainerUUIDs: su.ContainerUUIDs,
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
AccessCode: su.AccessCode,
|
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||||
CreatedAt: su.CreatedAt,
|
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||||
|
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||||
|
AccessCode: su.AccessCode,
|
||||||
|
CreatedAt: su.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +100,10 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
su.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,
|
||||||
@@ -114,14 +124,16 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
accessCode := generateRandomStr(8)
|
accessCode := generateRandomStr(8)
|
||||||
|
|
||||||
subUser := config.SubUser{
|
subUser := config.SubUser{
|
||||||
ID: "sub-" + generateRandomStr(8),
|
ID: "sub-" + generateRandomStr(8),
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
PassHash: string(hash),
|
PassHash: string(hash),
|
||||||
ContainerNames: []string{containerName},
|
ContainerNames: []string{containerName},
|
||||||
ContainerUUIDs: []string{c.UUID},
|
ContainerUUIDs: []string{c.UUID},
|
||||||
AccessCode: accessCode,
|
AllowedImageIDs: effectiveContainerAllowedImageIDs(c),
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
ImageLimitConfigured: true,
|
||||||
|
AccessCode: accessCode,
|
||||||
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
|
|
||||||
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
|
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
|
||||||
@@ -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
|
||||||
@@ -580,18 +741,21 @@ func splitBy(s, sep string) []string {
|
|||||||
|
|
||||||
// SubUserListItem is the enriched sub-user info returned by the list API
|
// SubUserListItem is the enriched sub-user info returned by the list API
|
||||||
type SubUserListItem struct {
|
type SubUserListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
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"`
|
||||||
ContainerName string `json:"container_name"`
|
AllowedImageIDs []string `json:"allowed_image_ids"`
|
||||||
ContainerUUID string `json:"container_uuid"`
|
ImageLimitConfigured bool `json:"image_limit_configured"`
|
||||||
AccessCode string `json:"access_code"`
|
CurrentImageIDs []string `json:"current_image_ids"`
|
||||||
Password string `json:"password,omitempty"`
|
ContainerName string `json:"container_name"`
|
||||||
CreatedAt string `json:"created_at"`
|
ContainerUUID string `json:"container_uuid"`
|
||||||
LastLogin string `json:"last_login"`
|
AccessCode string `json:"access_code"`
|
||||||
LastLoginIP string `json:"last_login_ip"`
|
Password string `json:"password,omitempty"`
|
||||||
LastLoginUA string `json:"last_login_ua"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastLogin string `json:"last_login"`
|
||||||
|
LastLoginIP string `json:"last_login_ip"`
|
||||||
|
LastLoginUA string `json:"last_login_ua"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSubUserList returns the list of all sub-users with container info
|
// HandleSubUserList returns the list of all sub-users with container info
|
||||||
@@ -607,13 +771,16 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
|||||||
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||||
for _, su := range config.AppConfig.SubUsers {
|
for _, su := range config.AppConfig.SubUsers {
|
||||||
item := SubUserListItem{
|
item := SubUserListItem{
|
||||||
ID: su.ID,
|
ID: su.ID,
|
||||||
Username: su.Username,
|
Username: su.Username,
|
||||||
ContainerNames: su.ContainerNames,
|
ContainerNames: su.ContainerNames,
|
||||||
ContainerUUIDs: su.ContainerUUIDs,
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
AccessCode: su.AccessCode,
|
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||||
Password: su.Password,
|
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||||
CreatedAt: su.CreatedAt,
|
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||||
|
AccessCode: su.AccessCode,
|
||||||
|
Password: su.Password,
|
||||||
|
CreatedAt: su.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve container name from first active UUID
|
// Resolve container name from first active UUID
|
||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()})
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -319,16 +377,18 @@ func DeleteApiKey(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SubUser struct {
|
type SubUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
PassHash string `json:"pass_hash"`
|
PassHash string `json:"pass_hash"`
|
||||||
ContainerNames []string `json:"container_names"`
|
ContainerNames []string `json:"container_names"`
|
||||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
Token string `json:"-"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
AccessCode string `json:"access_code"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
Token string `json:"-"`
|
||||||
TokenVersion int `json:"token_version"`
|
AccessCode string `json:"access_code"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
TokenVersion int `json:"token_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
|
|||||||
@@ -20,37 +20,44 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type savedTaskConfig struct {
|
type savedTaskConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
DiskGB int `json:"disk_gb"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
NetworkDownMbps int `json:"network_down_mbps"`
|
NetworkDownMbps int `json:"network_down_mbps"`
|
||||||
NetworkUpMbps int `json:"network_up_mbps"`
|
NetworkUpMbps int `json:"network_up_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficMode string `json:"traffic_mode"`
|
TrafficMode string `json:"traffic_mode"`
|
||||||
TrafficInGB int `json:"traffic_in_gb"`
|
TrafficInGB int `json:"traffic_in_gb"`
|
||||||
TrafficOutGB int `json:"traffic_out_gb"`
|
TrafficOutGB int `json:"traffic_out_gb"`
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
IOReadMBps int `json:"io_read_mbps"`
|
IOReadMBps int `json:"io_read_mbps"`
|
||||||
IOWriteMBps int `json:"io_write_mbps"`
|
IOWriteMBps int `json:"io_write_mbps"`
|
||||||
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"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||||
AssignIPv4 bool `json:"assign_ipv4"`
|
LANInterface string `json:"lan_interface,omitempty"`
|
||||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
SSHPassword string `json:"ssh_password,omitempty"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -376,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)
|
||||||
@@ -567,11 +571,13 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
}
|
}
|
||||||
return sshPassword
|
return sshPassword
|
||||||
}(),
|
}(),
|
||||||
PortMappings: portMappings,
|
PortMappings: portMappings,
|
||||||
PortMappingLimit: cfg.PortMappingCount,
|
PortMappingLimit: cfg.PortMappingCount,
|
||||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
|
||||||
CreatedAt: now,
|
ImageLimitConfigured: cfg.ImageLimitConfigured,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}
|
}
|
||||||
container.NormalizeNetworkAssignments()
|
container.NormalizeNetworkAssignments()
|
||||||
return container, nil
|
return container, nil
|
||||||
|
|||||||
@@ -46,12 +46,25 @@ func amd64Images() []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",
|
||||||
@@ -118,6 +131,12 @@ func arm64Images() []Image {
|
|||||||
Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM",
|
Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM",
|
||||||
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
|
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",
|
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
||||||
Distro: "debian", Release: "bookworm", Arch: "arm64",
|
Distro: "debian", Release: "bookworm", Arch: "arm64",
|
||||||
|
|||||||
+440
-70
@@ -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
|
||||||
}
|
}
|
||||||
@@ -218,37 +227,44 @@ func NewManager() *Manager {
|
|||||||
|
|
||||||
// ContainerConfig defines container creation parameters
|
// ContainerConfig defines container creation parameters
|
||||||
type ContainerConfig struct {
|
type ContainerConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
DiskGB int `json:"disk_gb"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
NetworkDownMbps int `json:"network_down_mbps"`
|
NetworkDownMbps int `json:"network_down_mbps"`
|
||||||
NetworkUpMbps int `json:"network_up_mbps"`
|
NetworkUpMbps int `json:"network_up_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||||
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
||||||
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
IOReadMBps int `json:"io_read_mbps"`
|
IOReadMBps int `json:"io_read_mbps"`
|
||||||
IOWriteMBps int `json:"io_write_mbps"`
|
IOWriteMBps int `json:"io_write_mbps"`
|
||||||
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"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||||
AssignIPv4 bool `json:"assign_ipv4"`
|
LANInterface string `json:"lan_interface,omitempty"`
|
||||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
SSHPassword string `json:"ssh_password,omitempty"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *ContainerConfig) NormalizeResourceAliases() {
|
func (cfg *ContainerConfig) NormalizeResourceAliases() {
|
||||||
@@ -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 {
|
||||||
@@ -424,44 +467,53 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
trafficResetDate := now[:7] // YYYY-MM for monthly tracking
|
trafficResetDate := now[:7] // YYYY-MM for monthly tracking
|
||||||
|
|
||||||
container := config.Container{
|
container := config.Container{
|
||||||
ID: id,
|
ID: id,
|
||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
Virtualization: config.VirtualizationLXC,
|
Virtualization: config.VirtualizationLXC,
|
||||||
Template: cfg.TemplateID,
|
LXCName: lxcName,
|
||||||
VCPU: cfg.VCPU,
|
Template: cfg.TemplateID,
|
||||||
RAMMB: cfg.RAMMB,
|
VCPU: cfg.VCPU,
|
||||||
DiskGB: cfg.DiskGB,
|
RAMMB: cfg.RAMMB,
|
||||||
NetworkBWMbps: cfg.NetworkBWMbps,
|
DiskGB: cfg.DiskGB,
|
||||||
NetworkDownMbps: cfg.NetworkDownMbps,
|
NetworkBWMbps: cfg.NetworkBWMbps,
|
||||||
NetworkUpMbps: cfg.NetworkUpMbps,
|
NetworkDownMbps: cfg.NetworkDownMbps,
|
||||||
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
|
NetworkUpMbps: cfg.NetworkUpMbps,
|
||||||
TrafficMode: trafficMode,
|
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
|
||||||
TrafficInGB: cfg.TrafficInGB,
|
TrafficMode: trafficMode,
|
||||||
TrafficOutGB: cfg.TrafficOutGB,
|
TrafficInGB: cfg.TrafficInGB,
|
||||||
TrafficResetDate: trafficResetDate,
|
TrafficOutGB: cfg.TrafficOutGB,
|
||||||
IOSpeedMBps: cfg.IOSpeedMBps,
|
TrafficResetDate: trafficResetDate,
|
||||||
IOReadMBps: cfg.IOReadMBps,
|
IOSpeedMBps: cfg.IOSpeedMBps,
|
||||||
IOWriteMBps: cfg.IOWriteMBps,
|
IOReadMBps: cfg.IOReadMBps,
|
||||||
Status: "stopped",
|
IOWriteMBps: cfg.IOWriteMBps,
|
||||||
IP: "",
|
Status: "stopped",
|
||||||
PublicIPv4s: publicIPv4s,
|
IP: "",
|
||||||
IPv6Addresses: ipv6Assignments,
|
LANIPv4Mode: normalizedLANIPv4Mode(cfg.LANIPv4Mode),
|
||||||
VNCPort: 0,
|
LANInterface: strings.TrimSpace(cfg.LANInterface),
|
||||||
SSHPort: sshPort,
|
LANIPv4Address: strings.TrimSpace(cfg.LANIPv4Address),
|
||||||
SSHPassword: sshPassword,
|
LANIPv4PrefixLen: cfg.LANIPv4PrefixLen,
|
||||||
PortMappings: portMappings,
|
LANIPv4Gateway: strings.TrimSpace(cfg.LANIPv4Gateway),
|
||||||
PortMappingLimit: cfg.PortMappingCount,
|
MACAddress: readLXCConfigValue(lxcName, "lxc.net.0.hwaddr"),
|
||||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
PublicIPv4s: publicIPv4s,
|
||||||
CreatedAt: now,
|
IPv6Addresses: ipv6Assignments,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
VNCPort: 0,
|
||||||
|
SSHPort: sshPort,
|
||||||
|
SSHPassword: sshPassword,
|
||||||
|
PortMappings: portMappings,
|
||||||
|
PortMappingLimit: cfg.PortMappingCount,
|
||||||
|
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
|
||||||
|
ImageLimitConfigured: cfg.ImageLimitConfigured,
|
||||||
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
}
|
}
|
||||||
container.NormalizeNetworkAssignments()
|
container.NormalizeNetworkAssignments()
|
||||||
config.AddContainer(container)
|
config.AddContainer(container)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1452,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1465,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
|
||||||
}
|
}
|
||||||
@@ -1488,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
|
||||||
}
|
}
|
||||||
@@ -1765,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
|
||||||
}
|
}
|
||||||
@@ -1786,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
|
||||||
@@ -1795,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2542,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
|
||||||
@@ -2557,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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2816,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ func GetTemplates() []Template {
|
|||||||
Distro: "ubuntu", Release: "jammy", Arch: arch,
|
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: arch,
|
Distro: "debian", Release: "bookworm", Arch: arch,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.1.23"
|
Version = "1.1.24"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-3
@@ -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
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.23",
|
"version": "1.1.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -107,7 +126,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
}, [hostInfo, kvmAvailable, form.virtualization])
|
}, [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
|
||||||
@@ -160,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)
|
||||||
@@ -197,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 || '请稍后重试')
|
||||||
@@ -241,7 +271,7 @@ 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 容器
|
||||||
@@ -252,7 +282,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (kvmAvailable) {
|
if (kvmAvailable) {
|
||||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))
|
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'}`}
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
@@ -270,7 +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) => (
|
||||||
@@ -283,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>
|
||||||
@@ -342,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"
|
||||||
/>
|
/>
|
||||||
@@ -408,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">
|
||||||
@@ -451,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"
|
||||||
@@ -711,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 {
|
||||||
@@ -724,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 || []) : [],
|
||||||
@@ -746,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 {
|
||||||
@@ -771,6 +948,28 @@ 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',
|
||||||
|
|||||||
@@ -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
|
} catch (err) {
|
||||||
const networkTx = nextUsage.network_tx_bps || 0
|
console.error('Failed to fetch metric history:', err)
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
}, [containerIdentifier, container?.uuid])
|
||||||
setHistory((prev) => {
|
|
||||||
const cutoff = Date.now() - statsRanges['1w']
|
|
||||||
const next = [...prev.filter((item) => item.ts >= cutoff), point]
|
|
||||||
localStorage.setItem(historyKey(currentContainer.uuid || containerIdentifier), JSON.stringify(next))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [containerIdentifier])
|
|
||||||
|
|
||||||
const 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}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -967,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',
|
||||||
@@ -976,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',
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.23</p>
|
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.24</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +258,19 @@ export interface HostInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
generated_at: string
|
generated_at: string
|
||||||
hostname: string
|
hostname: string
|
||||||
@@ -353,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
|
||||||
@@ -475,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
|
||||||
@@ -572,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
|
||||||
@@ -586,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[]
|
||||||
@@ -657,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 = () =>
|
||||||
@@ -667,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')
|
||||||
|
|
||||||
@@ -771,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
|
||||||
}
|
}
|
||||||
@@ -778,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
|
||||||
|
|||||||
@@ -537,6 +537,7 @@ 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" \
|
||||||
@@ -546,6 +547,7 @@ remove_clicd_lxc_image_cache() {
|
|||||||
"rockylinux 10 amd64" \
|
"rockylinux 10 amd64" \
|
||||||
"ubuntu noble arm64" \
|
"ubuntu noble arm64" \
|
||||||
"ubuntu jammy arm64" \
|
"ubuntu jammy arm64" \
|
||||||
|
"debian trixie arm64" \
|
||||||
"debian bookworm arm64" \
|
"debian bookworm arm64" \
|
||||||
"debian bullseye arm64" \
|
"debian bullseye arm64" \
|
||||||
"alpine 3.21 arm64" \
|
"alpine 3.21 arm64" \
|
||||||
|
|||||||
Reference in New Issue
Block a user