mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
31 Commits
v1.1.24
...
a28ae727f3
| Author | SHA1 | Date | |
|---|---|---|---|
| a28ae727f3 | |||
| 57d6b19c05 | |||
| bae028ade4 | |||
| 2188bddb93 | |||
| 79de3d5552 | |||
| 70679b6fbb | |||
| d739dcbaa4 | |||
| 9eaf5002df | |||
| 73433c035d | |||
| 5474991a6d | |||
| 37b16b83a5 | |||
| 38debab1aa | |||
| 24204609a1 | |||
| deedf86c22 | |||
| 8283b88ded | |||
| 6c9f24bb24 | |||
| f2fa2449e9 | |||
| 53d56be8f9 | |||
| ec38ab9136 | |||
| fdcd7df9e9 | |||
| d6d46296fe | |||
| 3f44c7565f | |||
| 61d842d94c | |||
| ca303d33f6 | |||
| 6bdeafccf2 | |||
| 04cefe0cf1 | |||
| f28117bc5e | |||
| 2324494dd7 | |||
| ebba97f1d6 | |||
| 3dabd93d2f | |||
| 8bad52bd9e |
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: "22.22.0"
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22.22.0"
|
||||
cache: npm
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
|
||||
|
||||
@@ -72,3 +72,4 @@ backend/clicd
|
||||
api.md
|
||||
deploy-arm.ps1
|
||||
deploy-dhcp.ps1
|
||||
deploy-pve-windows.ps1
|
||||
|
||||
@@ -119,10 +119,4 @@ This open-source software is intended solely for educational purposes, specifica
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
[](https://meteor-history.com)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type panelAccessPolicyResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowedSources []string `json:"allowed_sources"`
|
||||
TrustedProxies []string `json:"trusted_proxies"`
|
||||
CurrentSource string `json:"current_source"`
|
||||
DirectSource string `json:"direct_source"`
|
||||
UsingForwarded bool `json:"using_forwarded"`
|
||||
}
|
||||
|
||||
func HandlePanelAccessPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: panelAccessPolicyStatus(r, config.AppConfig.PanelAccessPolicy)})
|
||||
case http.MethodPut:
|
||||
updatePanelAccessPolicy(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func updatePanelAccessPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
var requested config.PanelAccessPolicy
|
||||
if err := json.NewDecoder(r.Body).Decode(&requested); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
normalized, err := config.NormalizePanelAccessPolicy(requested)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
decision := evaluatePanelRequest(r, normalized)
|
||||
if normalized.Enabled && !decision.Allowed {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Message: "The new access policy does not allow your current source address " + decision.CurrentSource,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
previous := config.AppConfig.PanelAccessPolicy
|
||||
config.AppConfig.PanelAccessPolicy = normalized
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
config.AppConfig.PanelAccessPolicy = previous
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save panel access policy"})
|
||||
return
|
||||
}
|
||||
detail := "enabled=" + strings.ToLower(strings.TrimSpace(boolText(normalized.Enabled))) +
|
||||
",allowed=" + strings.Join(normalized.AllowedSources, ",") +
|
||||
",trusted_proxies=" + strings.Join(normalized.TrustedProxies, ",")
|
||||
auditRequest(r, "settings.panel_access", "Panel access policy", detail, true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Panel access policy saved",
|
||||
Data: panelAccessPolicyStatus(r, normalized),
|
||||
})
|
||||
}
|
||||
|
||||
func panelAccessPolicyStatus(r *http.Request, policy config.PanelAccessPolicy) panelAccessPolicyResponse {
|
||||
decision := evaluatePanelRequest(r, policy)
|
||||
return panelAccessPolicyResponse{
|
||||
Enabled: policy.Enabled,
|
||||
AllowedSources: append([]string(nil), policy.AllowedSources...),
|
||||
TrustedProxies: append([]string(nil), policy.TrustedProxies...),
|
||||
CurrentSource: decision.CurrentSource,
|
||||
DirectSource: decision.DirectSource,
|
||||
UsingForwarded: decision.UsedForwarded,
|
||||
}
|
||||
}
|
||||
|
||||
func evaluatePanelRequest(r *http.Request, policy config.PanelAccessPolicy) config.PanelAccessDecision {
|
||||
return config.EvaluatePanelAccess(policy, r.RemoteAddr, config.ForwardedClientHeaders{
|
||||
ForwardedFor: r.Header.Get("X-Forwarded-For"),
|
||||
RealIP: r.Header.Get("X-Real-IP"),
|
||||
CFConnectingIP: r.Header.Get("CF-Connecting-IP"),
|
||||
})
|
||||
}
|
||||
|
||||
func boolText(value bool) string {
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -172,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
assignIPv6(w, r, id)
|
||||
case action == "public-ipv4" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
updatePublicIPv4(w, r, id)
|
||||
case action == "ipv6-addresses" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "ipv6:assign") {
|
||||
return
|
||||
}
|
||||
updateIPv6Addresses(w, r, id)
|
||||
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||
handleContainerSnapshots(w, r, id, action)
|
||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||
@@ -264,11 +274,13 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := lxc.ValidateCreateNATPortAvailability(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg.PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||
@@ -299,6 +311,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateCreateStoragePool(&cfg); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateCreateSSHAuth(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
|
||||
+568
-42
@@ -2,19 +2,24 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
"clicd/internal/safehttp"
|
||||
)
|
||||
|
||||
// ImageInfo represents a template image with its download/enable status.
|
||||
@@ -37,10 +42,19 @@ type ImageInfo struct {
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ManualPath string `json:"manual_path,omitempty"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
Provisioner string `json:"provisioner,omitempty"`
|
||||
Custom bool `json:"custom,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
var customImageFieldPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
|
||||
var sha256Pattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`)
|
||||
|
||||
var imageDownloadsMu sync.Mutex
|
||||
var imageDownloads = map[string]*imageDownloadStatus{}
|
||||
var lxcImageCacheMu sync.Mutex
|
||||
var lxcImageDownloadMu sync.Mutex
|
||||
var lxcImageDownloadActive bool
|
||||
|
||||
type imageDownloadStatus struct {
|
||||
Downloading bool
|
||||
@@ -136,6 +150,22 @@ func isImageDownloadActive(id string) bool {
|
||||
return st != nil && st.Downloading
|
||||
}
|
||||
|
||||
func beginLXCImageDownload() bool {
|
||||
lxcImageDownloadMu.Lock()
|
||||
defer lxcImageDownloadMu.Unlock()
|
||||
if lxcImageDownloadActive {
|
||||
return false
|
||||
}
|
||||
lxcImageDownloadActive = true
|
||||
return true
|
||||
}
|
||||
|
||||
func endLXCImageDownload() {
|
||||
lxcImageDownloadMu.Lock()
|
||||
lxcImageDownloadActive = false
|
||||
lxcImageDownloadMu.Unlock()
|
||||
}
|
||||
|
||||
func lxcImageDownloadTempName(id string) string {
|
||||
return fmt.Sprintf("clicd-img-dl-%s", id)
|
||||
}
|
||||
@@ -157,46 +187,68 @@ func cleanupOldImageDownloadErrors() {
|
||||
}
|
||||
}
|
||||
|
||||
// isImageDownloaded checks if the LXC download cache exists for a template.
|
||||
func isImageDownloaded(distro, release, arch string) bool {
|
||||
downloaded, _ := imageDownloadedInfo(distro, release, arch)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
// imageDownloadedInfo returns whether the image is downloaded and its total size in bytes.
|
||||
func imageDownloadedInfo(distro, release, arch string) (bool, int64) {
|
||||
cachePath := filepath.Join("/var/cache/lxc/download", distro, release, arch)
|
||||
func imageDownloadedInfo(templateID string) (bool, int64) {
|
||||
cachePath, ok := officialLXCImageCachePath(templateID)
|
||||
if !ok {
|
||||
return false, 0
|
||||
}
|
||||
info, err := os.Stat(cachePath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return false, 0
|
||||
}
|
||||
// Check directly for rootfs.tar.xz (some LXC versions store it here)
|
||||
if fi, err := os.Stat(filepath.Join(cachePath, "rootfs.tar.xz")); err == nil {
|
||||
return true, fi.Size()
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(cachePath, "meta.tar.xz")); err == nil {
|
||||
return true, fi.Size()
|
||||
}
|
||||
// Check one level deeper (LXC uses variant subdirectories like "default")
|
||||
entries, err := os.ReadDir(cachePath)
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
subPath := filepath.Join(cachePath, entry.Name())
|
||||
if fi, err := os.Stat(filepath.Join(subPath, "rootfs.tar.xz")); err == nil {
|
||||
return true, fi.Size()
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(subPath, "meta.tar.xz")); err == nil {
|
||||
return true, fi.Size()
|
||||
for _, candidate := range []string{
|
||||
filepath.Join(cachePath, "rootfs.tar.xz"),
|
||||
filepath.Join(cachePath, "meta.tar.xz"),
|
||||
filepath.Join(cachePath, "default", "rootfs.tar.xz"),
|
||||
filepath.Join(cachePath, "default", "meta.tar.xz"),
|
||||
} {
|
||||
if fileInfo, err := os.Stat(candidate); err == nil && !fileInfo.IsDir() {
|
||||
return true, fileInfo.Size()
|
||||
}
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
func officialLXCImageCachePath(templateID string) (string, bool) {
|
||||
arch := "amd64"
|
||||
if runtime.GOARCH == "arm64" {
|
||||
arch = "arm64"
|
||||
}
|
||||
base := "/var/cache/lxc/download"
|
||||
switch templateID {
|
||||
case "ubuntu-noble":
|
||||
return filepath.Join(base, "ubuntu", "noble", arch), true
|
||||
case "ubuntu-jammy":
|
||||
return filepath.Join(base, "ubuntu", "jammy", arch), true
|
||||
case "debian-trixie":
|
||||
return filepath.Join(base, "debian", "trixie", arch), true
|
||||
case "debian-bookworm":
|
||||
return filepath.Join(base, "debian", "bookworm", arch), true
|
||||
case "debian-bullseye":
|
||||
return filepath.Join(base, "debian", "bullseye", arch), true
|
||||
case "alpine-3.21":
|
||||
return filepath.Join(base, "alpine", "3.21", arch), true
|
||||
case "centos-9-stream":
|
||||
return filepath.Join(base, "centos", "9-Stream", arch), true
|
||||
case "archlinux-current":
|
||||
return filepath.Join(base, "archlinux", "current", arch), true
|
||||
case "fedora-44":
|
||||
return filepath.Join(base, "fedora", "44", arch), true
|
||||
case "rockylinux-10":
|
||||
return filepath.Join(base, "rockylinux", "10", arch), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func lxcTemplateDownloadedInfo(template lxc.Template) (bool, int64) {
|
||||
if template.Custom {
|
||||
return lxc.CustomImageDownloadedInfo(template.ID)
|
||||
}
|
||||
return imageDownloadedInfo(template.ID)
|
||||
}
|
||||
|
||||
// getEnabledImageSet returns the set of enabled image IDs.
|
||||
// If none have been explicitly set, all templates are enabled by default.
|
||||
func getEnabledImageSet() map[string]bool {
|
||||
@@ -235,10 +287,10 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
if kvmAvailable {
|
||||
kvmImages = kvm.GetImages()
|
||||
}
|
||||
images := make([]ImageInfo, 0, len(templates)+len(kvmImages))
|
||||
images := make([]ImageInfo, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
dl := imageDownloadInfo(t.ID)
|
||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||
downloaded, size := lxcTemplateDownloadedInfo(t)
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
@@ -256,13 +308,15 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
Stage: dl.Stage,
|
||||
Error: dl.Error,
|
||||
SizeBytes: size,
|
||||
Custom: t.Custom,
|
||||
SHA256: t.SHA256,
|
||||
})
|
||||
}
|
||||
for _, t := range kvmImages {
|
||||
dl := imageDownloadInfo(t.ID)
|
||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||
manualPath := ""
|
||||
if t.Distro == "windows" {
|
||||
if t.IsWindows() {
|
||||
manualPath = kvm.ImagePath(t.ID)
|
||||
}
|
||||
images = append(images, ImageInfo{
|
||||
@@ -284,12 +338,260 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
SizeBytes: size,
|
||||
ManualPath: manualPath,
|
||||
Desktop: t.Desktop,
|
||||
Provisioner: t.Provisioner,
|
||||
Custom: t.Custom,
|
||||
SHA256: t.SHA256,
|
||||
})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
||||
}
|
||||
|
||||
// HandleCustomKVMImages creates or removes administrator-defined LXC/KVM image sources.
|
||||
func HandleCustomKVMImages(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
if !requireScope(w, r, "image:download") {
|
||||
return
|
||||
}
|
||||
handleCustomKVMImageCreate(w, r)
|
||||
case http.MethodDelete:
|
||||
if !requireScope(w, r, "image:delete") {
|
||||
return
|
||||
}
|
||||
handleCustomKVMImageDelete(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCustomKVMImageCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
URL string `json:"url"`
|
||||
Provisioner string `json:"provisioner"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if req.Type == "" {
|
||||
req.Type = config.VirtualizationKVM
|
||||
}
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
req.Distro = strings.ToLower(strings.TrimSpace(req.Distro))
|
||||
req.Release = strings.ToLower(strings.TrimSpace(req.Release))
|
||||
req.Arch = strings.ToLower(strings.TrimSpace(req.Arch))
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.Provisioner = strings.ToLower(strings.TrimSpace(req.Provisioner))
|
||||
req.SHA256 = strings.ToLower(strings.TrimSpace(req.SHA256))
|
||||
|
||||
if req.Name == "" || len(req.Name) > 100 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "name must be between 1 and 100 characters"})
|
||||
return
|
||||
}
|
||||
if len(req.Description) > 500 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "description must not exceed 500 characters"})
|
||||
return
|
||||
}
|
||||
if req.Arch != runtime.GOARCH || (req.Arch != "amd64" && req.Arch != "arm64") {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "image architecture must match the host architecture"})
|
||||
return
|
||||
}
|
||||
if req.Type == config.VirtualizationLXC {
|
||||
if !customImageFieldPattern.MatchString(req.Distro) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"})
|
||||
return
|
||||
}
|
||||
if !customImageFieldPattern.MatchString(req.Release) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"})
|
||||
return
|
||||
}
|
||||
} else if req.Type == config.VirtualizationKVM {
|
||||
switch req.Provisioner {
|
||||
case config.KVMProvisionerLinuxCloudInit:
|
||||
if !customImageFieldPattern.MatchString(req.Distro) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"})
|
||||
return
|
||||
}
|
||||
if !customImageFieldPattern.MatchString(req.Release) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"})
|
||||
return
|
||||
}
|
||||
case config.KVMProvisionerWindows10:
|
||||
if req.Arch != "amd64" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"})
|
||||
return
|
||||
}
|
||||
req.Distro = "windows"
|
||||
req.Release = "10"
|
||||
case config.KVMProvisionerWindows11:
|
||||
if req.Arch != "amd64" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"})
|
||||
return
|
||||
}
|
||||
req.Distro = "windows"
|
||||
req.Release = "11"
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "unsupported unattended installation template"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "type must be lxc or kvm"})
|
||||
return
|
||||
}
|
||||
if len(req.URL) > 4096 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "url must not exceed 4096 characters"})
|
||||
return
|
||||
}
|
||||
if _, err := safehttp.ValidateURL(req.URL); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.SHA256 != "" && !sha256Pattern.MatchString(req.SHA256) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "sha256 must contain exactly 64 hexadecimal characters"})
|
||||
return
|
||||
}
|
||||
if req.Type == config.VirtualizationLXC {
|
||||
for _, existing := range lxc.GetTemplates() {
|
||||
if strings.EqualFold(existing.Name, req.Name) {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"})
|
||||
return
|
||||
}
|
||||
if existing.Custom && existing.URL == req.URL {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"})
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, existing := range kvm.GetImages() {
|
||||
if strings.EqualFold(existing.Name, req.Name) {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"})
|
||||
return
|
||||
}
|
||||
if existing.Custom && existing.URL == req.URL {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
random := make([]byte, 5)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to generate image ID"})
|
||||
return
|
||||
}
|
||||
createdAt := time.Now().Format("2006-01-02 15:04:05")
|
||||
if req.Type == config.VirtualizationLXC {
|
||||
image := config.CustomLXCImage{
|
||||
ID: "custom-lxc-" + hex.EncodeToString(random),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Distro: req.Distro,
|
||||
Release: req.Release,
|
||||
Arch: req.Arch,
|
||||
URL: req.URL,
|
||||
SHA256: req.SHA256,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
if err := config.AddCustomLXCImage(image); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image})
|
||||
return
|
||||
}
|
||||
image := config.CustomKVMImage{
|
||||
ID: "custom-kvm-" + hex.EncodeToString(random), Name: req.Name, Description: req.Description,
|
||||
Distro: req.Distro, Release: req.Release, Arch: req.Arch, URL: req.URL,
|
||||
Provisioner: req.Provisioner, SHA256: req.SHA256, CreatedAt: createdAt,
|
||||
}
|
||||
if err := config.AddCustomKVMImage(image); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image})
|
||||
}
|
||||
|
||||
func handleCustomKVMImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.ID) == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "id required"})
|
||||
return
|
||||
}
|
||||
req.ID = strings.TrimSpace(req.ID)
|
||||
kvmImage := kvm.FindImage(req.ID)
|
||||
lxcImage := lxc.FindTemplate(req.ID)
|
||||
isCustomKVM := kvmImage != nil && kvmImage.Custom
|
||||
isCustomLXC := lxcImage != nil && lxcImage.Custom
|
||||
if !isCustomKVM && !isCustomLXC {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"})
|
||||
return
|
||||
}
|
||||
if isImageDownloadActive(req.ID) {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before removing the source"})
|
||||
return
|
||||
}
|
||||
for i := range config.AppConfig.Containers {
|
||||
if config.AppConfig.Containers[i].Template == req.ID {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still used by a container"})
|
||||
return
|
||||
}
|
||||
}
|
||||
for i := range config.AppConfig.Tasks {
|
||||
task := &config.AppConfig.Tasks[i]
|
||||
if task.Status != "pending" && task.Status != "running" {
|
||||
continue
|
||||
}
|
||||
var taskConfig struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(task.Config), &taskConfig)
|
||||
if task.TemplateID == req.ID || taskConfig.TemplateID == req.ID {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still referenced by an active task"})
|
||||
return
|
||||
}
|
||||
}
|
||||
var deleteErr error
|
||||
if isCustomLXC {
|
||||
deleteErr = lxc.DeleteCustomImage(req.ID)
|
||||
} else {
|
||||
deleteErr = kvm.DeleteImage(req.ID)
|
||||
}
|
||||
if deleteErr != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + deleteErr.Error()})
|
||||
return
|
||||
}
|
||||
removeImageEnabled(req.ID)
|
||||
var removed bool
|
||||
var err error
|
||||
if isCustomLXC {
|
||||
removed, err = config.RemoveCustomLXCImage(req.ID)
|
||||
} else {
|
||||
removed, err = config.RemoveCustomKVMImage(req.ID)
|
||||
}
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to remove custom image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if !removed {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"})
|
||||
return
|
||||
}
|
||||
clearImageDownload(req.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Custom image removed"})
|
||||
}
|
||||
|
||||
// HandleImageDownload starts a template image download in the background.
|
||||
func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -307,7 +609,6 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
image := kvm.FindImage(req.TemplateID)
|
||||
@@ -319,6 +620,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
|
||||
return
|
||||
}
|
||||
if _, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||
ensureImageEnabled(image.ID)
|
||||
clearImageDownload(image.ID)
|
||||
@@ -359,22 +664,79 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||
return
|
||||
}
|
||||
if !beginLXCImageDownload() {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Another LXC image download is active"})
|
||||
return
|
||||
}
|
||||
lxcDownloadHandedOff := false
|
||||
defer func() {
|
||||
if !lxcDownloadHandedOff {
|
||||
endLXCImageDownload()
|
||||
}
|
||||
}()
|
||||
imagePool, err := config.SelectStoragePoolForContent(
|
||||
config.StorageContentImages,
|
||||
"",
|
||||
dirSizeBytes("/var/cache/lxc/download")+1024*1024*1024,
|
||||
)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ensureLXCImageCachePool(*imagePool); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Already downloaded? Just enable if needed.
|
||||
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
|
||||
if downloaded, _ := lxcTemplateDownloadedInfo(*tmpl); downloaded {
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
clearImageDownload(tmpl.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
|
||||
startStage := "lxc-create"
|
||||
if tmpl.Custom {
|
||||
startStage = "downloading"
|
||||
}
|
||||
ctx, ok := startImageDownload(tmpl.ID, startStage)
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||
return
|
||||
}
|
||||
|
||||
if tmpl.Custom {
|
||||
go func(tmpl lxc.Template) {
|
||||
defer endLXCImageDownload()
|
||||
err := lxc.DownloadCustomImageWithProgress(ctx, tmpl, func(progress lxc.CustomImageDownloadProgress) {
|
||||
updateImageDownload(tmpl.ID, func(status *imageDownloadStatus) {
|
||||
status.Stage = progress.Stage
|
||||
status.DownloadedBytes = progress.DownloadedBytes
|
||||
status.TotalBytes = progress.TotalBytes
|
||||
status.Progress = progress.Percent
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
_ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp")
|
||||
_ = os.Remove(lxc.CustomImagePath(tmpl.ID))
|
||||
finishImageDownload(tmpl.ID, nil)
|
||||
return
|
||||
}
|
||||
finishImageDownload(tmpl.ID, err)
|
||||
return
|
||||
}
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
finishImageDownload(tmpl.ID, nil)
|
||||
}(*tmpl)
|
||||
lxcDownloadHandedOff = true
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||
return
|
||||
}
|
||||
|
||||
go func(tmpl lxc.Template) {
|
||||
defer endLXCImageDownload()
|
||||
// Download via lxc-create with a temp container, then destroy it.
|
||||
tmpName := lxcImageDownloadTempName(tmpl.ID)
|
||||
args := []string{"-n", tmpName, "-t", "download", "--",
|
||||
@@ -386,7 +748,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
st.Stage = "lxc-create"
|
||||
})
|
||||
cmd := exec.CommandContext(ctx, "lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
output, err := runLXCImageDownloadCommand(cmd, tmpl.ID)
|
||||
|
||||
// Clean up the temp container unconditionally.
|
||||
cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||
@@ -403,10 +765,154 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
finishImageDownload(tmpl.ID, nil)
|
||||
}(*tmpl)
|
||||
lxcDownloadHandedOff = true
|
||||
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||
}
|
||||
|
||||
type lxcImageDownloadCommandResult struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func runLXCImageDownloadCommand(cmd *exec.Cmd, templateID string) ([]byte, error) {
|
||||
startedAt := time.Now()
|
||||
done := make(chan lxcImageDownloadCommandResult, 1)
|
||||
go func() {
|
||||
output, err := cmd.CombinedOutput()
|
||||
done <- lxcImageDownloadCommandResult{output: output, err: err}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
var lastBytes int64
|
||||
for {
|
||||
select {
|
||||
case result := <-done:
|
||||
return result.output, result.err
|
||||
case <-ticker.C:
|
||||
downloadedBytes := newestLXCRootfsDownloadSize(startedAt)
|
||||
if downloadedBytes <= 0 || downloadedBytes == lastBytes {
|
||||
continue
|
||||
}
|
||||
lastBytes = downloadedBytes
|
||||
updateImageDownload(templateID, func(st *imageDownloadStatus) {
|
||||
st.Stage = "downloading"
|
||||
st.DownloadedBytes = downloadedBytes
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newestLXCRootfsDownloadSize(startedAt time.Time) int64 {
|
||||
matches, _ := filepath.Glob("/tmp/tmp.*/rootfs.tar.xz")
|
||||
var newestTime time.Time
|
||||
var newestSize int64
|
||||
for _, match := range matches {
|
||||
info, err := os.Stat(match)
|
||||
if err != nil || info.IsDir() || info.ModTime().Before(startedAt.Add(-5*time.Second)) {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().After(newestTime) {
|
||||
newestTime = info.ModTime()
|
||||
newestSize = info.Size()
|
||||
}
|
||||
}
|
||||
return newestSize
|
||||
}
|
||||
|
||||
func ensureLXCImageCachePool(pool config.StoragePool) error {
|
||||
lxcImageCacheMu.Lock()
|
||||
defer lxcImageCacheMu.Unlock()
|
||||
|
||||
cachePath := "/var/cache/lxc/download"
|
||||
targetPath := filepath.Join(pool.Path, "images", "lxc")
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(targetAbs, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create LXC image storage: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Lstat(cachePath)
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(targetAbs, cachePath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourcePath := cachePath
|
||||
linked := info.Mode()&os.ModeSymlink != 0
|
||||
if linked {
|
||||
sourcePath, err = filepath.EvalSymlinks(cachePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve LXC image cache: %v", err)
|
||||
}
|
||||
}
|
||||
sourceAbs, err := filepath.Abs(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sourceAbs == targetAbs {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(targetAbs, sourceAbs+string(os.PathSeparator)) || strings.HasPrefix(sourceAbs, targetAbs+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("LXC image cache source and target must not be nested")
|
||||
}
|
||||
if !info.IsDir() && !linked {
|
||||
return fmt.Errorf("LXC image cache is not a directory: %s", cachePath)
|
||||
}
|
||||
|
||||
if output, err := exec.Command("cp", "-a", sourceAbs+string(os.PathSeparator)+".", targetAbs+string(os.PathSeparator)).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to migrate LXC image cache: %v, output: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
tempLink := fmt.Sprintf("%s.clicd-new-%d", cachePath, time.Now().UnixNano())
|
||||
if err := os.Symlink(targetAbs, tempLink); err != nil {
|
||||
return err
|
||||
}
|
||||
if linked {
|
||||
if err := os.Rename(tempLink, cachePath); err != nil {
|
||||
_ = os.Remove(tempLink)
|
||||
return fmt.Errorf("failed to switch LXC image cache: %v", err)
|
||||
}
|
||||
if isManagedLXCImageCachePath(sourceAbs) {
|
||||
_ = os.RemoveAll(sourceAbs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
backupPath := fmt.Sprintf("%s.clicd-backup-%d", cachePath, time.Now().UnixNano())
|
||||
if err := os.Rename(cachePath, backupPath); err != nil {
|
||||
_ = os.Remove(tempLink)
|
||||
return fmt.Errorf("failed to prepare LXC image cache migration: %v", err)
|
||||
}
|
||||
if err := os.Rename(tempLink, cachePath); err != nil {
|
||||
_ = os.Rename(backupPath, cachePath)
|
||||
_ = os.Remove(tempLink)
|
||||
return fmt.Errorf("failed to activate LXC image storage: %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(backupPath); err != nil {
|
||||
return fmt.Errorf("LXC image cache migrated but old cache cleanup failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isManagedLXCImageCachePath(path string) bool {
|
||||
path = filepath.Clean(path)
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
|
||||
if path == filepath.Clean(filepath.Join(pool.Path, "images", "lxc")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return path == filepath.Clean("/var/lib/clicd/images/lxc")
|
||||
}
|
||||
|
||||
// HandleImageCancel cancels an in-progress image download.
|
||||
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -442,7 +948,12 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
||||
os.Remove(kvm.ImagePath(image.ID))
|
||||
}
|
||||
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
|
||||
go cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||
if tmpl.Custom {
|
||||
_ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp")
|
||||
_ = os.Remove(lxc.CustomImagePath(tmpl.ID))
|
||||
} else {
|
||||
go cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||
}
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
|
||||
}
|
||||
@@ -483,9 +994,22 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
if tmpl.Custom {
|
||||
if err := lxc.DeleteCustomImage(tmpl.ID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
|
||||
return
|
||||
}
|
||||
removeImageEnabled(tmpl.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove cache directory
|
||||
cachePath := filepath.Join("/var/cache/lxc/download", tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
cachePath, ok := officialLXCImageCachePath(tmpl.ID)
|
||||
if !ok {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template cache path is not managed by CLICD"})
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(cachePath); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{
|
||||
Success: false,
|
||||
@@ -582,7 +1106,7 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
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]) {
|
||||
if downloaded, _ := lxcTemplateDownloadedInfo(t); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
||||
@@ -619,7 +1143,8 @@ func isImageDownloadedForRuntime(templateID string, runtime string) bool {
|
||||
if tmpl == nil {
|
||||
return false
|
||||
}
|
||||
return isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
downloaded, _ := lxcTemplateDownloadedInfo(*tmpl)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
func isTemplateAvailableForRequest(r *http.Request, c *config.Container, templateID string, runtime string) bool {
|
||||
@@ -653,7 +1178,8 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||
return false
|
||||
}
|
||||
enabledSet := getEnabledImageSet()
|
||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
downloaded, _ := lxcTemplateDownloadedInfo(*tmpl)
|
||||
return enabledSet[tmpl.ID] && downloaded
|
||||
}
|
||||
|
||||
func hostKVMAvailable() bool {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomKVMImageCreateRejectsInvalidSource(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"name": "Invalid Source",
|
||||
"distro": "ubuntu",
|
||||
"release": "noble",
|
||||
"arch": runtime.GOARCH,
|
||||
"url": "file:///etc/passwd",
|
||||
"provisioner": "linux-cloud-init",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
HandleCustomKVMImages(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomKVMImageCreateRejectsArchitectureMismatch(t *testing.T) {
|
||||
otherArch := "arm64"
|
||||
if runtime.GOARCH == otherArch {
|
||||
otherArch = "amd64"
|
||||
}
|
||||
payload := map[string]string{
|
||||
"name": "Wrong Architecture",
|
||||
"distro": "ubuntu",
|
||||
"release": "noble",
|
||||
"arch": otherArch,
|
||||
"url": "https://example.test/image.qcow2",
|
||||
"provisioner": "linux-cloud-init",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
HandleCustomKVMImages(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomLXCImageCreateRejectsInvalidSource(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"type": "lxc",
|
||||
"name": "Invalid LXC Source",
|
||||
"distro": "alpine",
|
||||
"release": "3.21",
|
||||
"arch": runtime.GOARCH,
|
||||
"url": "file:///tmp/rootfs.tar.xz",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
HandleCustomKVMImages(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomImageCreateRejectsPrivateNetworkSource(t *testing.T) {
|
||||
for _, imageType := range []string{"lxc", "kvm"} {
|
||||
t.Run(imageType, func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"type": imageType,
|
||||
"name": "Private Network Source",
|
||||
"distro": "ubuntu",
|
||||
"release": "noble",
|
||||
"arch": runtime.GOARCH,
|
||||
"url": "http://169.254.169.254/latest/meta-data",
|
||||
"provisioner": "linux-cloud-init",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
HandleCustomKVMImages(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialLXCImageCachePathUsesAllowlist(t *testing.T) {
|
||||
cachePath, ok := officialLXCImageCachePath("debian-trixie")
|
||||
if !ok {
|
||||
t.Fatal("known template cache path was rejected")
|
||||
}
|
||||
normalized := filepath.ToSlash(cachePath)
|
||||
if !strings.Contains(normalized, "/debian/trixie/") {
|
||||
t.Fatalf("cache path = %q, want Debian trixie path", cachePath)
|
||||
}
|
||||
for _, templateID := range []string{
|
||||
"../../../etc",
|
||||
"custom-lxc-attacker",
|
||||
"debian-trixie/../../etc",
|
||||
} {
|
||||
if cachePath, ok := officialLXCImageCachePath(templateID); ok || cachePath != "" {
|
||||
t.Fatalf("officialLXCImageCachePath(%q) = %q, %v; want rejection", templateID, cachePath, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -22,3 +25,54 @@ func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
|
||||
}
|
||||
|
||||
type ipAssignmentRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Auto *bool `json:"auto,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Addresses []string `json:"addresses,omitempty"`
|
||||
}
|
||||
|
||||
func (req ipAssignmentRequest) allocation() ([]string, int, bool) {
|
||||
auto := req.Mode == "random" || req.Mode == "auto"
|
||||
if req.Mode == "custom" {
|
||||
auto = false
|
||||
}
|
||||
if req.Mode == "clear" || req.Mode == "none" {
|
||||
return nil, 0, false
|
||||
}
|
||||
if req.Auto != nil {
|
||||
auto = *req.Auto
|
||||
}
|
||||
return req.Addresses, req.Count, auto
|
||||
}
|
||||
|
||||
func updatePublicIPv4(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req ipAssignmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
addresses, count, auto := req.allocation()
|
||||
c, err := updatePublicIPv4ByRuntime(id, addresses, count, auto)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Public IPv4 assignments updated", Data: c})
|
||||
}
|
||||
|
||||
func updateIPv6Addresses(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req ipAssignmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
addresses, count, auto := req.allocation()
|
||||
c, err := updateIPv6ByRuntime(id, addresses, count, auto)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assignments updated", Data: c})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
// CaptureRuntimeRestoreState records which managed workloads are actually
|
||||
// running before the CLICD service exits. On the next host boot, only those
|
||||
// workloads are started again.
|
||||
func CaptureRuntimeRestoreState() {
|
||||
if config.AppConfig == nil {
|
||||
return
|
||||
}
|
||||
lxcManager := lxc.NewManager()
|
||||
kvmManager := kvm.NewManager()
|
||||
changed := false
|
||||
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
status, err := runtimeStatus(*c, lxcManager, kvmManager)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to capture runtime state for %s: %v\n", c.Name, err)
|
||||
continue
|
||||
}
|
||||
restore := status == "running"
|
||||
if c.RestoreOnHostBoot != restore {
|
||||
c.RestoreOnHostBoot = restore
|
||||
changed = true
|
||||
}
|
||||
if status != "" && c.Status != status {
|
||||
c.Status = status
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
fmt.Printf("Warning: failed to save host boot restore state: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StartHostBootRestore() {
|
||||
go RestoreHostBootState()
|
||||
}
|
||||
|
||||
func RestoreHostBootState() {
|
||||
if config.AppConfig == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
lxcManager := lxc.NewManager()
|
||||
kvmManager := kvm.NewManager()
|
||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||
|
||||
for _, c := range containers {
|
||||
if !c.RestoreOnHostBoot {
|
||||
continue
|
||||
}
|
||||
if c.PolicyBlocked {
|
||||
fmt.Printf("Skipping host boot restore for %s: policy blocked\n", c.Name)
|
||||
continue
|
||||
}
|
||||
if lxc.IsExpired(c) {
|
||||
fmt.Printf("Skipping host boot restore for %s: expired at %s\n", c.Name, c.ExpiresAt)
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := runtimeStatus(c, lxcManager, kvmManager)
|
||||
if err == nil && status == "running" {
|
||||
config.UpdateContainerStatusAndRestore(c.ID, "running", true)
|
||||
if !c.IsKVM() {
|
||||
_ = lxcManager.ApplyPortMappings(c.ID)
|
||||
} else {
|
||||
_ = lxc.NewManager().ApplyPortMappings(c.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Restoring workload after host boot: %s (ID=%d)\n", c.Name, c.ID)
|
||||
if c.IsKVM() {
|
||||
if err := kvmManager.StartContainer(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to restore KVM %s: %v\n", c.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := lxcManager.StartContainer(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to restore LXC %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
lxc.EnsureAllRunningPortMappings()
|
||||
}
|
||||
|
||||
func runtimeStatus(c config.Container, lxcManager *lxc.Manager, kvmManager *kvm.Manager) (string, error) {
|
||||
if c.IsKVM() {
|
||||
return kvmManager.GetContainerStatus(c.VirshName())
|
||||
}
|
||||
return lxcManager.GetContainerStatus(c.LxcName())
|
||||
}
|
||||
@@ -22,6 +22,11 @@ type nat4PortRange struct {
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
type nat4Networks struct {
|
||||
LXC config.NATNetwork `json:"lxc"`
|
||||
KVM config.NATNetwork `json:"kvm"`
|
||||
}
|
||||
|
||||
type nat4Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -72,6 +77,8 @@ type ipv6Route struct {
|
||||
type routingResponse struct {
|
||||
NAT4 routeCapacity `json:"nat4"`
|
||||
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||
NAT4NextPort int `json:"nat4_next_port"`
|
||||
NAT4Networks nat4Networks `json:"nat4_networks"`
|
||||
IPv4 routeCapacity `json:"ipv4"`
|
||||
LANDHCP routeCapacity `json:"lan_dhcp"`
|
||||
IPv6 routeCapacity `json:"ipv6"`
|
||||
@@ -237,6 +244,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
if nat4Remaining < 0 {
|
||||
nat4Remaining = 0
|
||||
}
|
||||
nat4NextPort, _ := config.PreviewSSHPortExcluding(nil)
|
||||
|
||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||
hostPublicIPv4 := lxc.DetectPublicIPv4()
|
||||
@@ -262,6 +270,11 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
Start: nat4StartPort,
|
||||
End: nat4EndPort,
|
||||
},
|
||||
NAT4NextPort: nat4NextPort,
|
||||
NAT4Networks: nat4Networks{
|
||||
LXC: config.LXCNATNetwork(),
|
||||
KVM: config.KVMNATNetwork(),
|
||||
},
|
||||
IPv4: routeCapacity{
|
||||
Used: ipv4Used,
|
||||
Remaining: strconv.Itoa(ipv4Remaining),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -24,3 +25,44 @@ func TestHandleRoutingGetAllowsRoutingWriteScope(t *testing.T) {
|
||||
t.Fatal("routing:write scope should be able to receive the routing response after updates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRoutingGetReturnsConfiguredNextNATPort(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 35000,
|
||||
NextSSHPort: 30000,
|
||||
Containers: []config.Container{{
|
||||
PortMappings: []config.PortMapping{{HostPort: 30000}},
|
||||
}},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/routing", nil)
|
||||
req = withAuthContext(req, AuthContext{
|
||||
Type: authTypeAPIKey,
|
||||
Scopes: []string{"routing:read"},
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
handleRoutingGet(rec, req)
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||
NAT4NextPort int `json:"nat4_next_port"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.Success {
|
||||
t.Fatalf("routing response was unsuccessful: %s", rec.Body.String())
|
||||
}
|
||||
if response.Data.NAT4PortRange.Start != 30000 || response.Data.NAT4PortRange.End != 35000 {
|
||||
t.Fatalf("NAT range = %+v", response.Data.NAT4PortRange)
|
||||
}
|
||||
if response.Data.NAT4NextPort != 30001 {
|
||||
t.Fatalf("next NAT port = %d, want 30001", response.Data.NAT4NextPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
||||
return lxcManager.AssignIPv6(id)
|
||||
}
|
||||
|
||||
func updatePublicIPv4ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
|
||||
}
|
||||
return lxcManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
|
||||
}
|
||||
|
||||
func updateIPv6ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.UpdateIPv6Assignments(id, requested, count, auto)
|
||||
}
|
||||
return lxcManager.UpdateIPv6Assignments(id, requested, count, auto)
|
||||
}
|
||||
|
||||
func usageByRuntime(id int) (map[string]interface{}, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
@@ -131,12 +147,12 @@ func trafficByRuntime(id int) map[string]interface{} {
|
||||
return lxcManager.GetTrafficInfo(id)
|
||||
}
|
||||
|
||||
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
|
||||
}
|
||||
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
|
||||
}
|
||||
|
||||
func deleteSnapshotByRuntime(snapshotID string) error {
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -48,6 +49,38 @@ func HandleLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTaskQueueSettings returns or updates the global task concurrency limit.
|
||||
func HandleTaskQueueSettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: globalQueue.Settings()})
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var req struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.Concurrency < 1 || req.Concurrency > config.MaxTaskConcurrency {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "任务并发数必须在 1 到 16 之间"})
|
||||
return
|
||||
}
|
||||
previous := config.AppConfig.TaskConcurrency
|
||||
config.AppConfig.TaskConcurrency = req.Concurrency
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
config.AppConfig.TaskConcurrency = previous
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存任务队列设置失败"})
|
||||
return
|
||||
}
|
||||
globalQueue.SetConcurrency(req.Concurrency)
|
||||
auditRequest(r, "settings.task_queue", "task_concurrency", fmt.Sprintf("concurrency=%d", req.Concurrency), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "任务队列设置已保存", Data: globalQueue.Settings()})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
||||
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
||||
config.AddLoginLog(username, ip, userAgent, success)
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -88,6 +89,20 @@ func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID
|
||||
|
||||
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
user := requestUser(r)
|
||||
var req struct {
|
||||
StoragePoolID string `json:"storage_pool_id"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
}
|
||||
req.StoragePoolID = strings.TrimSpace(req.StoragePoolID)
|
||||
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, req.StoragePoolID, 0); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) {
|
||||
c := config.FindContainer(containerID)
|
||||
limit := config.ContainerSnapshotLimit(c)
|
||||
@@ -96,7 +111,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
|
||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0, req.StoragePoolID)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -159,6 +174,12 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
|
||||
if req.Time == "" {
|
||||
req.Time = "03:00"
|
||||
}
|
||||
if req.Enabled {
|
||||
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, "", 0); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
user := requestUser(r)
|
||||
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type storageInfoResponse struct {
|
||||
Pools []storagePoolInfo `json:"pools"`
|
||||
Disks []storageDiskInfo `json:"disks"`
|
||||
ContentTypes []string `json:"content_types"`
|
||||
}
|
||||
|
||||
type storagePoolInfo struct {
|
||||
config.StoragePool
|
||||
Available bool `json:"available"`
|
||||
Exists bool `json:"exists"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
|
||||
ContentUsage []storageContentUsage `json:"content_usage"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type storageContentUsage struct {
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type storageDiskInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
FSType string `json:"fstype"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
Model string `json:"model"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
StoragePath string `json:"storage_path,omitempty"`
|
||||
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
|
||||
ContentUsage []storageContentUsage `json:"content_usage"`
|
||||
}
|
||||
|
||||
func HandleStorage(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
|
||||
case http.MethodPut:
|
||||
var req struct {
|
||||
Pools []config.StoragePool `json:"pools"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
pools, err := normalizeStoragePoolsRequest(req.Pools)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
for _, pool := range pools {
|
||||
if err := os.MkdirAll(pool.Path, 0755); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: fmt.Sprintf("Failed to create %s: %v", pool.Path, err)})
|
||||
return
|
||||
}
|
||||
}
|
||||
config.AppConfig.StoragePools = pools
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save storage pools"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func buildStorageInfo() storageInfoResponse {
|
||||
disks := detectStorageDisks()
|
||||
pools := make([]storagePoolInfo, 0, len(config.AppConfig.StoragePools))
|
||||
for _, pool := range config.AppConfig.StoragePools {
|
||||
info := storagePoolInfo{StoragePool: pool}
|
||||
if filepath.Clean(pool.MountPoint) == string(os.PathSeparator) {
|
||||
_ = os.MkdirAll(pool.Path, 0755)
|
||||
}
|
||||
if st, err := os.Stat(pool.Path); err == nil && st.IsDir() {
|
||||
info.Exists = true
|
||||
} else if err != nil {
|
||||
info.Error = err.Error()
|
||||
}
|
||||
detectedMountPoint := bestMountPointForPath(pool.Path, disks)
|
||||
if info.MountPoint == "" {
|
||||
info.MountPoint = detectedMountPoint
|
||||
}
|
||||
if detectedMountPoint != "" && filepath.Clean(info.MountPoint) == filepath.Clean(detectedMountPoint) {
|
||||
info.Available = info.Exists
|
||||
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(pool.Path)
|
||||
info.ContentUsage, info.ClicdUsedBytes = contentUsageForPool(pool.Path)
|
||||
} else if info.Error == "" {
|
||||
info.Error = "storage disk is not mounted"
|
||||
}
|
||||
pools = append(pools, info)
|
||||
}
|
||||
for i := range disks {
|
||||
for _, pool := range pools {
|
||||
if pool.MountPoint != disks[i].MountPoint {
|
||||
continue
|
||||
}
|
||||
disks[i].ClicdUsedBytes += pool.ClicdUsedBytes
|
||||
disks[i].ContentUsage = mergeContentUsage(disks[i].ContentUsage, pool.ContentUsage)
|
||||
if disks[i].StoragePoolID == "" {
|
||||
disks[i].StoragePoolID = pool.ID
|
||||
disks[i].StoragePath = pool.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
return storageInfoResponse{
|
||||
Pools: pools,
|
||||
Disks: disks,
|
||||
ContentTypes: []string{
|
||||
config.StorageContentLXC,
|
||||
config.StorageContentKVM,
|
||||
config.StorageContentImages,
|
||||
config.StorageContentSnapshots,
|
||||
config.StorageContentBackups,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStoragePoolsRequest(items []config.StoragePool) ([]config.StoragePool, error) {
|
||||
return normalizeStoragePoolsRequestWithDisks(items, detectStorageDisks())
|
||||
}
|
||||
|
||||
func normalizeStoragePoolsRequestWithDisks(items []config.StoragePool, disks []storageDiskInfo) ([]config.StoragePool, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("at least one mounted storage disk configuration must be retained")
|
||||
}
|
||||
result := make([]config.StoragePool, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
defaultSeen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
disk, managedPath, err := storageDiskForPoolRequest(item, disks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, name := storagePoolIdentity(disk)
|
||||
if seen[id] {
|
||||
return nil, fmt.Errorf("duplicate storage disk: %s", disk.MountPoint)
|
||||
}
|
||||
seen[id] = true
|
||||
|
||||
contentTypes := normalizeStorageContentTypes(item.ContentTypes)
|
||||
defaultContents := normalizeStorageContentTypes(item.DefaultContents)
|
||||
allowed := map[string]bool{}
|
||||
for _, content := range contentTypes {
|
||||
allowed[content] = true
|
||||
}
|
||||
defaults := make([]string, 0, len(defaultContents))
|
||||
for _, content := range defaultContents {
|
||||
if !allowed[content] {
|
||||
continue
|
||||
}
|
||||
if defaultSeen[content] {
|
||||
return nil, fmt.Errorf("only one default storage disk is allowed for %s", content)
|
||||
}
|
||||
defaultSeen[content] = true
|
||||
defaults = append(defaults, content)
|
||||
}
|
||||
result = append(result, config.StoragePool{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Path: managedPath,
|
||||
MountPoint: disk.MountPoint,
|
||||
ContentTypes: contentTypes,
|
||||
DefaultContents: defaults,
|
||||
Enabled: item.Enabled,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func storageDiskForPoolRequest(item config.StoragePool, disks []storageDiskInfo) (storageDiskInfo, string, error) {
|
||||
requestedMount := filepath.Clean(strings.TrimSpace(item.MountPoint))
|
||||
if requestedMount == "." {
|
||||
requestedMount = ""
|
||||
}
|
||||
requestedPath := filepath.Clean(strings.TrimSpace(item.Path))
|
||||
if requestedPath == "." {
|
||||
requestedPath = ""
|
||||
}
|
||||
for _, disk := range disks {
|
||||
mountPoint := filepath.Clean(disk.MountPoint)
|
||||
managedPath := managedStoragePath(mountPoint)
|
||||
mountMatches := requestedMount != "" && requestedMount == mountPoint
|
||||
pathMatches := requestedPath != "" && requestedPath == managedPath
|
||||
if !mountMatches && !pathMatches {
|
||||
continue
|
||||
}
|
||||
if requestedMount != "" && !mountMatches {
|
||||
return storageDiskInfo{}, "", fmt.Errorf("storage disk mount point has changed; refresh and try again")
|
||||
}
|
||||
if requestedPath != "" && !pathMatches {
|
||||
return storageDiskInfo{}, "", fmt.Errorf("custom storage paths are not allowed; refresh and try again")
|
||||
}
|
||||
return disk, managedPath, nil
|
||||
}
|
||||
return storageDiskInfo{}, "", fmt.Errorf("storage disk is not mounted or is no longer available")
|
||||
}
|
||||
|
||||
func storagePoolIdentity(disk storageDiskInfo) (string, string) {
|
||||
mountPoint := filepath.Clean(disk.MountPoint)
|
||||
if mountPoint == string(os.PathSeparator) {
|
||||
return "disk-root", "system (/)"
|
||||
}
|
||||
baseName := filepath.Base(mountPoint)
|
||||
if baseName == "" || baseName == "." || baseName == string(os.PathSeparator) {
|
||||
baseName = strings.TrimSpace(disk.Name)
|
||||
}
|
||||
if baseName == "" {
|
||||
baseName = "storage"
|
||||
}
|
||||
devicePath := strings.TrimSpace(disk.Path)
|
||||
if devicePath == "" {
|
||||
devicePath = strings.TrimSpace(disk.Name)
|
||||
}
|
||||
return "disk-" + storageID(baseName), fmt.Sprintf("%s (%s)", baseName, devicePath)
|
||||
}
|
||||
|
||||
func managedStoragePath(mountPoint string) string {
|
||||
if filepath.Clean(mountPoint) == string(os.PathSeparator) {
|
||||
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
|
||||
}
|
||||
return filepath.Join(filepath.Clean(mountPoint), "clicd")
|
||||
}
|
||||
|
||||
func normalizeStorageContentTypes(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, value := range values {
|
||||
var next string
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case config.StorageContentLXC:
|
||||
next = config.StorageContentLXC
|
||||
case config.StorageContentKVM:
|
||||
next = config.StorageContentKVM
|
||||
case config.StorageContentImages:
|
||||
next = config.StorageContentImages
|
||||
case config.StorageContentSnapshots:
|
||||
next = config.StorageContentSnapshots
|
||||
case config.StorageContentBackups:
|
||||
next = config.StorageContentBackups
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if seen[next] {
|
||||
continue
|
||||
}
|
||||
seen[next] = true
|
||||
result = append(result, next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func storageID(name string) string {
|
||||
id := strings.ToLower(strings.TrimSpace(name))
|
||||
id = strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-").Replace(id)
|
||||
id = strings.Trim(id, "-")
|
||||
if id == "" {
|
||||
return "storage"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func detectStorageDisks() []storageDiskInfo {
|
||||
type lsblkDevice struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
FSType string `json:"fstype"`
|
||||
MountPoint string `json:"mountpoint"`
|
||||
Model string `json:"model"`
|
||||
Size int64 `json:"size"`
|
||||
ReadOnly bool `json:"ro"`
|
||||
Children []lsblkDevice `json:"children"`
|
||||
}
|
||||
var payload struct {
|
||||
BlockDevices []lsblkDevice `json:"blockdevices"`
|
||||
}
|
||||
out, err := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RO").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(out, &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := []storageDiskInfo{}
|
||||
var walk func(lsblkDevice)
|
||||
walk = func(dev lsblkDevice) {
|
||||
info := storageDiskInfo{
|
||||
Name: dev.Name,
|
||||
Path: dev.Path,
|
||||
Type: dev.Type,
|
||||
FSType: dev.FSType,
|
||||
MountPoint: dev.MountPoint,
|
||||
Model: strings.TrimSpace(dev.Model),
|
||||
SizeBytes: dev.Size,
|
||||
}
|
||||
if isUsableStorageMount(dev.Type, dev.FSType, dev.Path, dev.MountPoint, dev.ReadOnly) && !mountIsReadOnly(dev.MountPoint) {
|
||||
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(dev.MountPoint)
|
||||
result = append(result, info)
|
||||
}
|
||||
for _, child := range dev.Children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, dev := range payload.BlockDevices {
|
||||
walk(dev)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isUsableStorageMount(deviceType, fsType, devicePath, mountPoint string, readOnly bool) bool {
|
||||
if readOnly || strings.TrimSpace(mountPoint) == "" || !strings.HasPrefix(mountPoint, "/") {
|
||||
return false
|
||||
}
|
||||
|
||||
deviceType = strings.ToLower(strings.TrimSpace(deviceType))
|
||||
devicePath = strings.ToLower(strings.TrimSpace(devicePath))
|
||||
if deviceType == "loop" || deviceType == "rom" || deviceType == "zram" || strings.HasPrefix(devicePath, "/dev/loop") {
|
||||
return false
|
||||
}
|
||||
|
||||
fsType = strings.ToLower(strings.TrimSpace(fsType))
|
||||
unsupportedFileSystems := map[string]bool{
|
||||
"": true,
|
||||
"squashfs": true,
|
||||
"iso9660": true,
|
||||
"udf": true,
|
||||
"swap": true,
|
||||
"tmpfs": true,
|
||||
"devtmpfs": true,
|
||||
"overlay": true,
|
||||
"proc": true,
|
||||
"sysfs": true,
|
||||
"cgroup": true,
|
||||
"cgroup2": true,
|
||||
"efivarfs": true,
|
||||
"securityfs": true,
|
||||
}
|
||||
if unsupportedFileSystems[fsType] {
|
||||
return false
|
||||
}
|
||||
|
||||
mountPoint = pathpkg.Clean(mountPoint)
|
||||
for _, reserved := range []string{"/snap", "/boot"} {
|
||||
if mountPoint == reserved || strings.HasPrefix(mountPoint, reserved+"/") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mountIsReadOnly(mountPoint string) bool {
|
||||
out, err := exec.Command("findmnt", "-n", "-o", "OPTIONS", "--target", mountPoint).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, option := range strings.Split(strings.TrimSpace(string(out)), ",") {
|
||||
if strings.TrimSpace(option) == "ro" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contentUsageForPool(poolPath string) ([]storageContentUsage, int64) {
|
||||
mapping := map[string]string{
|
||||
config.StorageContentLXC: "lxc",
|
||||
config.StorageContentKVM: "kvm",
|
||||
config.StorageContentImages: "images",
|
||||
config.StorageContentSnapshots: "snapshots",
|
||||
config.StorageContentBackups: "backups",
|
||||
}
|
||||
result := make([]storageContentUsage, 0, len(mapping))
|
||||
var total int64
|
||||
for _, content := range []string{
|
||||
config.StorageContentLXC,
|
||||
config.StorageContentKVM,
|
||||
config.StorageContentImages,
|
||||
config.StorageContentSnapshots,
|
||||
config.StorageContentBackups,
|
||||
} {
|
||||
size := dirSizeBytes(filepath.Join(poolPath, mapping[content]))
|
||||
result = append(result, storageContentUsage{ContentType: content, SizeBytes: size})
|
||||
total += size
|
||||
}
|
||||
return result, total
|
||||
}
|
||||
|
||||
func mergeContentUsage(current []storageContentUsage, next []storageContentUsage) []storageContentUsage {
|
||||
sizes := map[string]int64{}
|
||||
order := []string{}
|
||||
for _, item := range append(current, next...) {
|
||||
if _, ok := sizes[item.ContentType]; !ok {
|
||||
order = append(order, item.ContentType)
|
||||
}
|
||||
sizes[item.ContentType] += item.SizeBytes
|
||||
}
|
||||
result := make([]storageContentUsage, 0, len(order))
|
||||
for _, content := range order {
|
||||
result = append(result, storageContentUsage{ContentType: content, SizeBytes: sizes[content]})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dirSizeBytes(path string) int64 {
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||
path = resolved
|
||||
}
|
||||
// Count allocated blocks on this filesystem only. LXC rootfs directories can
|
||||
// contain active mounts such as proc/sys; traversing them is slow and reports
|
||||
// enormous virtual sizes that are not actually occupied by CLICD data.
|
||||
out, err := exec.Command("du", "-skx", path).Output()
|
||||
if err == nil {
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) > 0 {
|
||||
var sizeKB int64
|
||||
if _, scanErr := fmt.Sscanf(fields[0], "%d", &sizeKB); scanErr == nil && sizeKB <= (1<<63-1)/1024 {
|
||||
return sizeKB * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
var size int64
|
||||
_ = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info, statErr := d.Info(); statErr == nil {
|
||||
size += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return size
|
||||
}
|
||||
|
||||
func dfPath(path string) (size int64, used int64, free int64) {
|
||||
out, err := exec.Command("df", "-B1", "-P", path).Output()
|
||||
if err != nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) < 2 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
fields := strings.Fields(lines[len(lines)-1])
|
||||
if len(fields) < 6 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
fmt.Sscanf(fields[1], "%d", &size)
|
||||
fmt.Sscanf(fields[2], "%d", &used)
|
||||
fmt.Sscanf(fields[3], "%d", &free)
|
||||
return size, used, free
|
||||
}
|
||||
|
||||
func bestMountPointForPath(path string, disks []storageDiskInfo) string {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
path = pathpkg.Clean(path)
|
||||
best := ""
|
||||
for _, disk := range disks {
|
||||
mp := pathpkg.Clean(strings.ReplaceAll(disk.MountPoint, "\\", "/"))
|
||||
if disk.MountPoint == "" || mp == "." {
|
||||
continue
|
||||
}
|
||||
matches := path == mp
|
||||
if mp == "/" {
|
||||
matches = pathpkg.IsAbs(path)
|
||||
} else if strings.HasPrefix(path, mp+"/") {
|
||||
matches = true
|
||||
}
|
||||
if matches {
|
||||
if len(mp) > len(best) {
|
||||
best = mp
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestIsUsableStorageMount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deviceType string
|
||||
fsType string
|
||||
devicePath string
|
||||
mountPoint string
|
||||
readOnly bool
|
||||
wantUsable bool
|
||||
}{
|
||||
{name: "root partition", deviceType: "part", fsType: "ext4", devicePath: "/dev/sda2", mountPoint: "/", wantUsable: true},
|
||||
{name: "mounted data disk", deviceType: "disk", fsType: "xfs", devicePath: "/dev/sdb", mountPoint: "/data", wantUsable: true},
|
||||
{name: "snap loop", deviceType: "loop", fsType: "squashfs", devicePath: "/dev/loop0", mountPoint: "/snap/core20/2105", readOnly: true},
|
||||
{name: "loop without ro flag", deviceType: "loop", fsType: "ext4", devicePath: "/dev/loop7", mountPoint: "/mnt/loop"},
|
||||
{name: "read only disk", deviceType: "part", fsType: "ext4", devicePath: "/dev/sdc1", mountPoint: "/archive", readOnly: true},
|
||||
{name: "optical image", deviceType: "rom", fsType: "iso9660", devicePath: "/dev/sr0", mountPoint: "/media/cdrom"},
|
||||
{name: "efi partition", deviceType: "part", fsType: "vfat", devicePath: "/dev/sda1", mountPoint: "/boot/efi"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isUsableStorageMount(tt.deviceType, tt.fsType, tt.devicePath, tt.mountPoint, tt.readOnly)
|
||||
if got != tt.wantUsable {
|
||||
t.Fatalf("isUsableStorageMount() = %v, want %v", got, tt.wantUsable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestMountPointForPath(t *testing.T) {
|
||||
disks := []storageDiskInfo{
|
||||
{Path: "/dev/sda2", MountPoint: "/"},
|
||||
{Path: "/dev/sdb1", MountPoint: "/mnt/clicd-data"},
|
||||
}
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{path: "/var/lib/clicd", want: "/"},
|
||||
{path: "/mnt/clicd-data/clicd", want: "/mnt/clicd-data"},
|
||||
{path: "/mnt/clicd-data", want: "/mnt/clicd-data"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := bestMountPointForPath(tt.path, disks); got != tt.want {
|
||||
t.Fatalf("bestMountPointForPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStoragePoolsUsesServerManagedPath(t *testing.T) {
|
||||
disks := []storageDiskInfo{
|
||||
{Path: "/dev/sda2", MountPoint: "/"},
|
||||
{Path: "/dev/sdb1", MountPoint: "/mnt/data"},
|
||||
}
|
||||
items := []config.StoragePool{{
|
||||
ID: "disk-data",
|
||||
Name: "data",
|
||||
Path: "/mnt/data/clicd",
|
||||
MountPoint: "/mnt/data",
|
||||
ContentTypes: []string{config.StorageContentLXC},
|
||||
DefaultContents: []string{config.StorageContentLXC},
|
||||
Enabled: true,
|
||||
}}
|
||||
pools, err := normalizeStoragePoolsRequestWithDisks(items, disks)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPath := filepath.Join(filepath.Clean("/mnt/data"), "clicd")
|
||||
if len(pools) != 1 || pools[0].ID != "disk-data" || pools[0].Name != "data (/dev/sdb1)" || pools[0].Path != wantPath || pools[0].MountPoint != "/mnt/data" {
|
||||
t.Fatalf("unexpected normalized pools: %#v", pools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStoragePoolsRejectsUncontrolledPath(t *testing.T) {
|
||||
disks := []storageDiskInfo{{Path: "/dev/sdb1", MountPoint: "/mnt/data"}}
|
||||
for _, path := range []string{"/etc", "/mnt/data/clicd/../../etc", "/mnt/data/other"} {
|
||||
_, err := normalizeStoragePoolsRequestWithDisks([]config.StoragePool{{
|
||||
ID: "disk-data",
|
||||
Name: "data",
|
||||
Path: path,
|
||||
MountPoint: "/mnt/data",
|
||||
Enabled: true,
|
||||
}}, disks)
|
||||
if err == nil {
|
||||
t.Fatalf("path %q was accepted", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirSizeBytesUsesAllocatedBlocks(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("allocated-block behavior is provided by the Linux du command")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
file, err := os.Create(filepath.Join(dir, "sparse.img"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Truncate(1 << 30); err != nil {
|
||||
file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := dirSizeBytes(dir); got >= 128<<20 {
|
||||
t.Fatalf("dirSizeBytes() = %d, expected allocated size instead of 1 GiB apparent size", got)
|
||||
}
|
||||
}
|
||||
+365
-185
@@ -30,6 +30,8 @@ type Task struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
StageDetail string `json:"stage_detail,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Config lxc.ContainerConfig `json:"config,omitempty"`
|
||||
@@ -37,30 +39,74 @@ type Task struct {
|
||||
User string `json:"user,omitempty"` // who created this task
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
activeKey string
|
||||
}
|
||||
|
||||
type TaskQueue struct {
|
||||
mu sync.Mutex
|
||||
createQueue []*Task
|
||||
opQueue []*Task
|
||||
tasks map[string]*Task
|
||||
nextID int
|
||||
createCond *sync.Cond
|
||||
opCond *sync.Cond
|
||||
stop chan struct{}
|
||||
mu sync.Mutex
|
||||
createQueue []*Task
|
||||
opQueue []*Task
|
||||
tasks map[string]*Task
|
||||
nextID int
|
||||
createCond *sync.Cond
|
||||
opCond *sync.Cond
|
||||
maxConcurrency int
|
||||
activeTasks int
|
||||
activeTargets map[string]bool
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
type TaskQueueSettings struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
Active int `json:"active"`
|
||||
Pending int `json:"pending"`
|
||||
}
|
||||
|
||||
var globalQueue *TaskQueue
|
||||
|
||||
func init() {
|
||||
globalQueue = &TaskQueue{
|
||||
tasks: make(map[string]*Task),
|
||||
stop: make(chan struct{}),
|
||||
globalQueue = newTaskQueue(config.DefaultTaskConcurrency)
|
||||
go globalQueue.createDispatcher()
|
||||
go globalQueue.opDispatcher()
|
||||
}
|
||||
|
||||
func newTaskQueue(concurrency int) *TaskQueue {
|
||||
q := &TaskQueue{
|
||||
tasks: make(map[string]*Task),
|
||||
maxConcurrency: config.NormalizeTaskConcurrency(concurrency),
|
||||
activeTargets: make(map[string]bool),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
globalQueue.createCond = sync.NewCond(&globalQueue.mu)
|
||||
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
|
||||
go globalQueue.createWorker()
|
||||
go globalQueue.opWorker()
|
||||
q.createCond = sync.NewCond(&q.mu)
|
||||
q.opCond = sync.NewCond(&q.mu)
|
||||
return q
|
||||
}
|
||||
|
||||
func ConfigureTaskQueue(concurrency int) {
|
||||
globalQueue.SetConcurrency(concurrency)
|
||||
}
|
||||
|
||||
func (q *TaskQueue) SetConcurrency(concurrency int) {
|
||||
q.mu.Lock()
|
||||
q.maxConcurrency = config.NormalizeTaskConcurrency(concurrency)
|
||||
q.createCond.Broadcast()
|
||||
q.opCond.Broadcast()
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *TaskQueue) Settings() TaskQueueSettings {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return TaskQueueSettings{
|
||||
Concurrency: q.maxConcurrency,
|
||||
Active: q.activeTasks,
|
||||
Pending: len(q.createQueue) + len(q.opQueue),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *TaskQueue) signalDispatchers() {
|
||||
q.createCond.Broadcast()
|
||||
q.opCond.Broadcast()
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueTask(task *Task) {
|
||||
@@ -90,6 +136,8 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task
|
||||
ContainerID: containerID,
|
||||
ContainerName: containerName,
|
||||
Status: "pending",
|
||||
Stage: "queued",
|
||||
StageDetail: "排队等待",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
User: user,
|
||||
@@ -172,6 +220,8 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
|
||||
ContainerID: 0,
|
||||
ContainerName: cfgCopy.Name,
|
||||
Status: "pending",
|
||||
Stage: "queued",
|
||||
StageDetail: "排队等待",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Config: cfgCopy,
|
||||
User: user,
|
||||
@@ -202,6 +252,8 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
||||
ContainerID: containerID,
|
||||
ContainerName: containerName,
|
||||
Status: "pending",
|
||||
Stage: "queued",
|
||||
StageDetail: "排队等待",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
User: user,
|
||||
@@ -262,176 +314,253 @@ func (q *TaskQueue) CancelPendingSecurityStops() int {
|
||||
return cancelled
|
||||
}
|
||||
|
||||
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
|
||||
// If a restored task already has a same-name container in config, it resumes
|
||||
// initialization instead of creating another ct-{id}.
|
||||
func (q *TaskQueue) createWorker() {
|
||||
// The two dispatchers keep long-running creates from blocking power operations,
|
||||
// while sharing one global concurrency budget.
|
||||
func (q *TaskQueue) createDispatcher() {
|
||||
for {
|
||||
q.mu.Lock()
|
||||
for len(q.createQueue) == 0 {
|
||||
q.createCond.Wait()
|
||||
}
|
||||
task := q.createQueue[0]
|
||||
q.createQueue = q.createQueue[1:]
|
||||
task.Status = "running"
|
||||
q.mu.Unlock()
|
||||
|
||||
createdByTask := false
|
||||
if task.Config.Name == "" {
|
||||
task.Config.Name = task.ContainerName
|
||||
}
|
||||
task.Config.NormalizeResourceAliases()
|
||||
if task.Config.Name == "" {
|
||||
task.Status = "failed"
|
||||
task.Error = "container name is required"
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
c := config.FindContainerByName(task.Config.Name)
|
||||
if c == nil {
|
||||
// 1) Download image + apply limits (lxc-create)
|
||||
err := createByRuntime(task.Config)
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
createdByTask = true
|
||||
|
||||
// 2) Find created container by name
|
||||
c = config.FindContainerByName(task.Config.Name)
|
||||
if c == nil {
|
||||
task.Status = "failed"
|
||||
task.Error = "created but not found in config"
|
||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
task.ContainerID = c.ID
|
||||
task.ContainerName = c.Name
|
||||
|
||||
// 3) Start + initialize SSH/network in the same worker.
|
||||
// If init fails, destroy the container so no dead entry remains.
|
||||
startErr := startByRuntime(c.ID)
|
||||
if startErr != nil {
|
||||
if createdByTask {
|
||||
_ = destroyByRuntime(c.ID)
|
||||
}
|
||||
task.Status = "failed"
|
||||
task.Error = startErr.Error()
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin")
|
||||
} else {
|
||||
task.Status = "done"
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
task := q.takeNextTask(true)
|
||||
go q.runCreateTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
|
||||
// including the follow-up initialization after a create succeeds.
|
||||
func (q *TaskQueue) opWorker() {
|
||||
func (q *TaskQueue) opDispatcher() {
|
||||
for {
|
||||
q.mu.Lock()
|
||||
for len(q.opQueue) == 0 {
|
||||
q.opCond.Wait()
|
||||
}
|
||||
task := q.opQueue[0]
|
||||
q.opQueue = q.opQueue[1:]
|
||||
task.Status = "running"
|
||||
q.mu.Unlock()
|
||||
|
||||
var err error
|
||||
skipped := false
|
||||
err = resolveTaskContainer(task)
|
||||
// Block operations on expired or traffic-exceeded containers (except stop/delete)
|
||||
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||
c := config.FindContainer(task.ContainerID)
|
||||
if c != nil {
|
||||
if lxc.IsExpired(*c) {
|
||||
err = fmt.Errorf("容器已到期,不允许此操作")
|
||||
} else if lxc.IsTrafficExceeded(*c) {
|
||||
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
||||
skipped = true
|
||||
}
|
||||
if err == nil {
|
||||
if !skipped {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = startByRuntime(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = stopByRuntime(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = restartByRuntime(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = destroyByRuntime(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
if lxc.HasSSHAuthOptions(task.Config) {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||
} else {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
auditUser := task.User
|
||||
if auditUser == "" {
|
||||
auditUser = "admin"
|
||||
}
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||
} else if skipped {
|
||||
task.Status = "done"
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
|
||||
} else {
|
||||
task.Status = "done"
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskStop:
|
||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||
case TaskRestart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskReinstall:
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
}
|
||||
}
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
task := q.takeNextTask(false)
|
||||
go q.runOperationTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *TaskQueue) takeNextTask(create bool) *Task {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
cond := q.opCond
|
||||
if create {
|
||||
cond = q.createCond
|
||||
}
|
||||
for {
|
||||
queue := q.opQueue
|
||||
if create {
|
||||
queue = q.createQueue
|
||||
}
|
||||
if q.activeTasks < q.maxConcurrency {
|
||||
if index := runnableTaskIndex(queue, q.activeTargets); index >= 0 {
|
||||
task := queue[index]
|
||||
queue = append(queue[:index], queue[index+1:]...)
|
||||
if create {
|
||||
q.createQueue = queue
|
||||
} else {
|
||||
q.opQueue = queue
|
||||
}
|
||||
task.Status = "running"
|
||||
task.Error = ""
|
||||
task.Stage = "preparing"
|
||||
task.StageDetail = "准备初始化环境"
|
||||
task.activeKey = taskConcurrencyKey(task)
|
||||
q.activeTargets[task.activeKey] = true
|
||||
q.activeTasks++
|
||||
q.persistTasks()
|
||||
return task
|
||||
}
|
||||
}
|
||||
cond.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func runnableTaskIndex(queue []*Task, activeTargets map[string]bool) int {
|
||||
for index, task := range queue {
|
||||
if !activeTargets[taskConcurrencyKey(task)] {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func taskConcurrencyKey(task *Task) string {
|
||||
if task == nil {
|
||||
return "task:nil"
|
||||
}
|
||||
name := strings.TrimSpace(task.ContainerName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(task.Config.Name)
|
||||
}
|
||||
if name != "" {
|
||||
return "name:" + strings.ToLower(name)
|
||||
}
|
||||
if task.ContainerID > 0 {
|
||||
return fmt.Sprintf("id:%d", task.ContainerID)
|
||||
}
|
||||
return "task:" + task.ID
|
||||
}
|
||||
|
||||
func (q *TaskQueue) finishTask(task *Task, status string, taskErr error) {
|
||||
q.mu.Lock()
|
||||
task.Status = status
|
||||
if taskErr != nil {
|
||||
task.Error = taskErr.Error()
|
||||
if task.Type == TaskCreate {
|
||||
task.Stage = "failed"
|
||||
task.StageDetail = "初始化失败"
|
||||
}
|
||||
} else {
|
||||
task.Error = ""
|
||||
if task.Type == TaskCreate {
|
||||
task.Stage = "completed"
|
||||
task.StageDetail = "初始化完成"
|
||||
}
|
||||
}
|
||||
if task.activeKey != "" {
|
||||
delete(q.activeTargets, task.activeKey)
|
||||
task.activeKey = ""
|
||||
}
|
||||
if q.activeTasks > 0 {
|
||||
q.activeTasks--
|
||||
}
|
||||
q.persistTasks()
|
||||
q.signalDispatchers()
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *TaskQueue) updateTaskStage(task *Task, stage, detail string) {
|
||||
q.mu.Lock()
|
||||
task.Stage = stage
|
||||
task.StageDetail = detail
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
// runCreateTask handles lxc-create, resource setup, start, and SSH init. A
|
||||
// restored task resumes initialization when the same-name container exists.
|
||||
func (q *TaskQueue) runCreateTask(task *Task) {
|
||||
q.mu.Lock()
|
||||
createdByTask := false
|
||||
if task.Config.Name == "" {
|
||||
task.Config.Name = task.ContainerName
|
||||
}
|
||||
task.Config.NormalizeResourceAliases()
|
||||
cfg := task.Config
|
||||
q.mu.Unlock()
|
||||
cfg.Progress = func(stage, detail string) {
|
||||
q.updateTaskStage(task, stage, detail)
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
err := fmt.Errorf("container name is required")
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+err.Error(), "admin")
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
c := config.FindContainerByName(cfg.Name)
|
||||
if c == nil {
|
||||
if err := createByRuntime(cfg); err != nil {
|
||||
config.AddAuditLog(string(task.Type), cfg.Name, "失败: "+err.Error(), "admin")
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
createdByTask = true
|
||||
c = config.FindContainerByName(cfg.Name)
|
||||
if c == nil {
|
||||
err := fmt.Errorf("created but not found in config")
|
||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
lxc.ReleaseQueuedCreateNATPorts(cfg.Name)
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
task.ContainerID = c.ID
|
||||
task.ContainerName = c.Name
|
||||
q.mu.Unlock()
|
||||
startDetail := "启动容器并等待网络就绪"
|
||||
if strings.EqualFold(cfg.Virtualization, config.VirtualizationKVM) {
|
||||
startDetail = "启动虚拟机并等待网络就绪"
|
||||
}
|
||||
q.updateTaskStage(task, "starting", startDetail)
|
||||
if err := startByRuntime(c.ID); err != nil {
|
||||
if createdByTask {
|
||||
_ = destroyByRuntime(c.ID)
|
||||
}
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+err.Error(), "admin")
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
|
||||
q.finishTask(task, "done", nil)
|
||||
}
|
||||
|
||||
func (q *TaskQueue) runOperationTask(task *Task) {
|
||||
q.mu.Lock()
|
||||
err := resolveTaskContainer(task)
|
||||
q.mu.Unlock()
|
||||
skipped := false
|
||||
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||
c := config.FindContainer(task.ContainerID)
|
||||
if c != nil {
|
||||
if lxc.IsExpired(*c) {
|
||||
err = fmt.Errorf("容器已到期,不允许此操作")
|
||||
} else if lxc.IsTrafficExceeded(*c) {
|
||||
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
||||
skipped = true
|
||||
}
|
||||
if err == nil && !skipped {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = startByRuntime(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = stopByRuntime(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = restartByRuntime(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = destroyByRuntime(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
if lxc.HasSSHAuthOptions(task.Config) {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||
} else {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditUser := task.User
|
||||
if auditUser == "" {
|
||||
auditUser = "admin"
|
||||
}
|
||||
if err != nil {
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
if skipped {
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
|
||||
q.finishTask(task, "done", nil)
|
||||
return
|
||||
}
|
||||
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskStop:
|
||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||
case TaskRestart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskReinstall:
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
}
|
||||
q.finishTask(task, "done", nil)
|
||||
}
|
||||
|
||||
func isSecurityStopTask(task *Task) bool {
|
||||
return task != nil && task.Type == TaskStop && task.User == "system:security"
|
||||
}
|
||||
@@ -503,7 +632,8 @@ func (q *TaskQueue) GetTasks() []*Task {
|
||||
result := make([]*Task, 0, len(q.tasks))
|
||||
// Collect all task IDs, sort by creation time (extracted from ID number)
|
||||
for _, t := range q.tasks {
|
||||
result = append(result, t)
|
||||
copyTask := *t
|
||||
result = append(result, ©Task)
|
||||
}
|
||||
// Stable sort by ID number (task-N where N is sequential)
|
||||
for i := 0; i < len(result); i++ {
|
||||
@@ -622,6 +752,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
activeCreateNames := globalQueue.ActiveCreateNames()
|
||||
requestNames := make(map[string]bool)
|
||||
requestNATPorts := make(map[string]string)
|
||||
for i := range req.Containers {
|
||||
name := strings.TrimSpace(req.Containers[i].Name)
|
||||
req.Containers[i].Name = name
|
||||
@@ -660,6 +791,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Containers[i].DiskGB < 1 {
|
||||
req.Containers[i].DiskGB = 5
|
||||
}
|
||||
if err := validateCreateStoragePool(&req.Containers[i]); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||
return
|
||||
@@ -674,11 +809,29 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
|
||||
req.Containers[i].PortMappingCount = 2
|
||||
} else if !req.Containers[i].WantsNAT() {
|
||||
req.Containers[i].PortMappingCount = 0
|
||||
req.Containers[i].ExtraPorts = nil
|
||||
if err := req.Containers[i].NormalizeCreateNATMappings(); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := lxc.ValidateCreateNATPortAvailability(req.Containers[i]); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].ManagementPort > 0 {
|
||||
key := fmt.Sprintf("%d/tcp", req.Containers[i].ManagementPort)
|
||||
if owner := requestNATPorts[key]; owner != "" {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: fmt.Sprintf("%s: NAT management port %s is also requested by %s", name, key, owner)})
|
||||
return
|
||||
}
|
||||
requestNATPorts[key] = name
|
||||
}
|
||||
for _, mapping := range req.Containers[i].NATPortMappings {
|
||||
key := fmt.Sprintf("%d/%s", mapping.HostPort, mapping.Protocol)
|
||||
if owner := requestNATPorts[key]; owner != "" {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: fmt.Sprintf("%s: NAT host port %s is also requested by %s", name, key, owner)})
|
||||
return
|
||||
}
|
||||
requestNATPorts[key] = name
|
||||
}
|
||||
if req.Containers[i].PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
|
||||
@@ -715,7 +868,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
requestNames[name] = true
|
||||
}
|
||||
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
||||
planned, err := lxc.ReserveBatchCreateNATPorts(req.Containers)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
ids := globalQueue.EnqueueBatchCreateWithAudit(planned, requestActor(r), clientIP(r), r.UserAgent())
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||
}
|
||||
|
||||
@@ -837,7 +995,8 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
globalQueue.mu.Lock()
|
||||
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
|
||||
task := globalQueue.tasks[taskID]
|
||||
if task != nil && !isTaskAllowedForRequest(r, task) {
|
||||
globalQueue.mu.Unlock()
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
|
||||
return
|
||||
@@ -860,6 +1019,9 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
globalQueue.opQueue = newOp
|
||||
globalQueue.persistTasks()
|
||||
globalQueue.mu.Unlock()
|
||||
if task != nil && task.Type == TaskCreate {
|
||||
lxc.ReleaseQueuedCreateNATPorts(task.Config.Name)
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"})
|
||||
}
|
||||
|
||||
@@ -879,6 +1041,8 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// RestoreTasks restores task queue from config
|
||||
func RestoreTasks() {
|
||||
globalQueue.mu.Lock()
|
||||
defer globalQueue.mu.Unlock()
|
||||
for _, st := range config.AppConfig.Tasks {
|
||||
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
|
||||
continue
|
||||
@@ -908,6 +1072,8 @@ func RestoreTasks() {
|
||||
ContainerName: containerName,
|
||||
Status: st.Status,
|
||||
Error: st.Error,
|
||||
Stage: "queued",
|
||||
StageDetail: "排队等待",
|
||||
CreatedAt: st.CreatedAt,
|
||||
TemplateID: st.TemplateID,
|
||||
Config: cfg,
|
||||
@@ -937,3 +1103,17 @@ func parseIDNum(id string) int {
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func validateCreateStoragePool(cfg *lxc.ContainerConfig) error {
|
||||
required := config.StorageContentLXC
|
||||
if cfg.Virtualization == config.VirtualizationKVM {
|
||||
required = config.StorageContentKVM
|
||||
}
|
||||
requiredBytes := int64(cfg.DiskGB) * 1024 * 1024 * 1024
|
||||
pool, err := config.SelectStoragePoolForContent(required, cfg.StoragePoolID, requiredBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.StoragePoolID = pool.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
func TestRunnableTaskIndexSkipsActiveContainer(t *testing.T) {
|
||||
queue := []*Task{
|
||||
{ID: "task-1", Type: TaskStop, ContainerID: 1, ContainerName: "alpha"},
|
||||
{ID: "task-2", Type: TaskStart, ContainerID: 1, ContainerName: "alpha"},
|
||||
{ID: "task-3", Type: TaskStart, ContainerID: 2, ContainerName: "beta"},
|
||||
}
|
||||
active := map[string]bool{taskConcurrencyKey(queue[0]): true}
|
||||
|
||||
if got := runnableTaskIndex(queue[1:], active); got != 1 {
|
||||
t.Fatalf("runnableTaskIndex() = %d, want 1 for the other container", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskConcurrencyKeyUsesContainerName(t *testing.T) {
|
||||
create := &Task{ID: "task-1", Type: TaskCreate, Config: lxcConfigWithName("Example")}
|
||||
operation := &Task{ID: "task-2", Type: TaskDelete, ContainerID: 9, ContainerName: "example"}
|
||||
if taskConcurrencyKey(create) != taskConcurrencyKey(operation) {
|
||||
t.Fatalf("same container received different concurrency keys: %q and %q", taskConcurrencyKey(create), taskConcurrencyKey(operation))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskQueueSetConcurrencyNormalizesAndReports(t *testing.T) {
|
||||
q := newTaskQueue(config.DefaultTaskConcurrency)
|
||||
q.SetConcurrency(config.MaxTaskConcurrency + 10)
|
||||
if got := q.Settings().Concurrency; got != config.MaxTaskConcurrency {
|
||||
t.Fatalf("concurrency = %d, want %d", got, config.MaxTaskConcurrency)
|
||||
}
|
||||
q.SetConcurrency(0)
|
||||
if got := q.Settings().Concurrency; got != config.DefaultTaskConcurrency {
|
||||
t.Fatalf("concurrency = %d, want default %d", got, config.DefaultTaskConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskQueueUpdateTaskStage(t *testing.T) {
|
||||
q := newTaskQueue(config.DefaultTaskConcurrency)
|
||||
task := &Task{ID: "task-1", Type: TaskCreate, Status: "running"}
|
||||
|
||||
q.updateTaskStage(task, "rootfs", "下载模板并创建基础文件系统")
|
||||
|
||||
if task.Stage != "rootfs" || task.StageDetail != "下载模板并创建基础文件系统" {
|
||||
t.Fatalf("unexpected task stage: %q %q", task.Stage, task.StageDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func lxcConfigWithName(name string) lxc.ContainerConfig {
|
||||
return lxc.ContainerConfig{Name: name}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// RunAccessPolicyCommand manages the panel source policy without requiring the
|
||||
// interactive menu. It is intended to remain usable over SSH as a recovery path.
|
||||
func RunAccessPolicyCommand(args []string) error {
|
||||
action := "show"
|
||||
if len(args) > 0 {
|
||||
action = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
args = args[1:]
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "show":
|
||||
printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy)
|
||||
return nil
|
||||
case "disable", "off":
|
||||
next := config.AppConfig.PanelAccessPolicy
|
||||
next.Enabled = false
|
||||
if err := savePanelAccessPolicy(next); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Panel access allowlist disabled.")
|
||||
return reloadPanelAfterAccessPolicyCommand()
|
||||
case "set", "enable":
|
||||
flags := flag.NewFlagSet("clicd access-policy set", flag.ContinueOnError)
|
||||
flags.SetOutput(new(strings.Builder))
|
||||
var allowed string
|
||||
var trusted string
|
||||
flags.StringVar(&allowed, "allow", "", "comma-separated allowed IP/CIDR values")
|
||||
flags.StringVar(&trusted, "trusted-proxy", "", "comma-separated trusted proxy IP/CIDR values")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return fmt.Errorf("invalid access-policy arguments: %w", err)
|
||||
}
|
||||
next := config.PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: splitPanelAccessEntries(allowed),
|
||||
TrustedProxies: splitPanelAccessEntries(trusted),
|
||||
}
|
||||
if err := savePanelAccessPolicy(next); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Panel access allowlist saved.")
|
||||
printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy)
|
||||
return reloadPanelAfterAccessPolicyCommand()
|
||||
default:
|
||||
return fmt.Errorf("unknown access-policy action %q; use show, set, or disable", action)
|
||||
}
|
||||
}
|
||||
|
||||
func savePanelAccessPolicy(policy config.PanelAccessPolicy) error {
|
||||
normalized, err := config.NormalizePanelAccessPolicy(policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous := config.AppConfig.PanelAccessPolicy
|
||||
config.AppConfig.PanelAccessPolicy = normalized
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
config.AppConfig.PanelAccessPolicy = previous
|
||||
return fmt.Errorf("save panel access policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadPanelAfterAccessPolicyCommand() error {
|
||||
if !isWebPanelRunning() {
|
||||
return nil
|
||||
}
|
||||
if err := restartService("clicd"); err != nil {
|
||||
return fmt.Errorf("policy was saved but clicd service restart failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPanelAccessPolicy(policy config.PanelAccessPolicy) {
|
||||
fmt.Printf("Enabled: %t\n", policy.Enabled)
|
||||
fmt.Printf("Allowed sources: %s\n", strings.Join(policy.AllowedSources, ", "))
|
||||
fmt.Printf("Trusted proxies: %s\n", strings.Join(policy.TrustedProxies, ", "))
|
||||
}
|
||||
@@ -54,6 +54,7 @@ var cliTranslations = map[string]string{
|
||||
"导入现有 LXC 容器": "Import existing LXC containers",
|
||||
"检查并升级 CLICD": "Check and upgrade CLICD",
|
||||
"卸载 CLICD": "Uninstall CLICD",
|
||||
"面板访问白名单": "Panel access allowlist",
|
||||
"系统信息": "System info",
|
||||
"退出": "Exit",
|
||||
"获取容器列表失败": "Failed to get container list",
|
||||
@@ -175,10 +176,26 @@ var cliTranslations = map[string]string{
|
||||
"LXC 版本": "LXC version",
|
||||
"暂无可用容器": "No available containers",
|
||||
"忽略无效端口": "Ignoring invalid port",
|
||||
"?": "? ",
|
||||
"。": ". ",
|
||||
",": ", ",
|
||||
":": ": ",
|
||||
"面板访问来源策略": "Panel access source policy",
|
||||
"当前状态": "Current status",
|
||||
"已启用": "enabled",
|
||||
"已关闭": "disabled",
|
||||
"允许来源": "Allowed sources",
|
||||
"可信代理": "Trusted proxies",
|
||||
"启用或修改白名单": "Enable or update allowlist",
|
||||
"关闭白名单限制": "Disable allowlist",
|
||||
"取消": "Cancel",
|
||||
"允许的 IP/CIDR,多个用逗号分隔": "Allowed IP/CIDR values, comma-separated",
|
||||
"可信代理 IP/CIDR,多个用逗号分隔,可留空": "Trusted proxy IP/CIDR values, comma-separated; optional",
|
||||
"白名单配置无效": "Invalid allowlist configuration",
|
||||
"保存访问来源策略失败": "Failed to save access source policy",
|
||||
"面板访问白名单已保存。": "Panel access allowlist saved.",
|
||||
"面板访问白名单已关闭。": "Panel access allowlist disabled.",
|
||||
"至少填写一个允许的 IP 或网段。": "Enter at least one allowed IP address or network.",
|
||||
"?": "? ",
|
||||
"。": ". ",
|
||||
",": ", ",
|
||||
":": ": ",
|
||||
}
|
||||
|
||||
// Run starts the CLI interface.
|
||||
@@ -193,7 +210,7 @@ func Run() {
|
||||
refreshCLILanguage()
|
||||
clearScreen()
|
||||
printMenu()
|
||||
cliPrint("\n请选择操作 [1-12,l,0/q]: ")
|
||||
cliPrint("\n请选择操作 [1-13,l,0/q]: ")
|
||||
input, _ := reader.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
@@ -246,6 +263,10 @@ func Run() {
|
||||
clearScreen()
|
||||
cliUninstall(reader)
|
||||
return
|
||||
case "13":
|
||||
clearScreen()
|
||||
cliConfigurePanelAccess(reader)
|
||||
waitEnter(reader)
|
||||
case "0":
|
||||
clearScreen()
|
||||
cliShowInfo()
|
||||
@@ -293,11 +314,80 @@ func printMenu() {
|
||||
cliPrintln(" 10. 导入现有 LXC 容器")
|
||||
cliPrintln(" 11. 检查并升级 CLICD")
|
||||
cliPrintln(" 12. 卸载 CLICD")
|
||||
cliPrintln(" 13. 面板访问白名单")
|
||||
cliPrintln(" 0. 系统信息")
|
||||
cliPrintln(" l. 切换语言")
|
||||
cliPrintln(" q. 退出")
|
||||
}
|
||||
|
||||
func cliConfigurePanelAccess(reader *bufio.Reader) {
|
||||
cliPrintf("\n--- %s ---\n", cliT("面板访问来源策略"))
|
||||
policy := config.AppConfig.PanelAccessPolicy
|
||||
status := cliT("已关闭")
|
||||
if policy.Enabled {
|
||||
status = cliT("已启用")
|
||||
}
|
||||
cliPrintf("%s: %s\n", cliT("当前状态"), status)
|
||||
cliPrintf("%s: %s\n", cliT("允许来源"), strings.Join(policy.AllowedSources, ", "))
|
||||
cliPrintf("%s: %s\n", cliT("可信代理"), strings.Join(policy.TrustedProxies, ", "))
|
||||
cliPrintf("\n 1. %s\n", cliT("启用或修改白名单"))
|
||||
cliPrintf(" 2. %s\n", cliT("关闭白名单限制"))
|
||||
cliPrintf(" 0. %s\n", cliT("取消"))
|
||||
|
||||
choice := promptString(reader, "请选择操作", "0")
|
||||
next := policy
|
||||
switch strings.TrimSpace(choice) {
|
||||
case "1":
|
||||
allowed := promptString(reader, "允许的 IP/CIDR,多个用逗号分隔", strings.Join(policy.AllowedSources, ","))
|
||||
allowedSources := splitPanelAccessEntries(allowed)
|
||||
if len(allowedSources) == 0 {
|
||||
cliPrintln("至少填写一个允许的 IP 或网段。")
|
||||
return
|
||||
}
|
||||
trusted := promptString(reader, "可信代理 IP/CIDR,多个用逗号分隔,可留空", strings.Join(policy.TrustedProxies, ","))
|
||||
next = config.PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: allowedSources,
|
||||
TrustedProxies: splitPanelAccessEntries(trusted),
|
||||
}
|
||||
case "2":
|
||||
next.Enabled = false
|
||||
case "0", "":
|
||||
cliPrintln("已取消")
|
||||
return
|
||||
default:
|
||||
cliPrintln("无效选择")
|
||||
return
|
||||
}
|
||||
|
||||
normalized, err := config.NormalizePanelAccessPolicy(next)
|
||||
if err != nil {
|
||||
cliPrintf("%s: %v\n", cliT("白名单配置无效"), err)
|
||||
return
|
||||
}
|
||||
previous := config.AppConfig.PanelAccessPolicy
|
||||
config.AppConfig.PanelAccessPolicy = normalized
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
config.AppConfig.PanelAccessPolicy = previous
|
||||
cliPrintf("%s: %v\n", cliT("保存访问来源策略失败"), err)
|
||||
return
|
||||
}
|
||||
if normalized.Enabled {
|
||||
cliPrintln("面板访问白名单已保存。")
|
||||
} else {
|
||||
cliPrintln("面板访问白名单已关闭。")
|
||||
}
|
||||
if isWebPanelRunning() {
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
}
|
||||
|
||||
func splitPanelAccessEntries(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||
})
|
||||
}
|
||||
|
||||
func cliSwitchLanguage(reader *bufio.Reader) {
|
||||
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
|
||||
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
|
||||
@@ -1244,8 +1334,8 @@ func removeCLICDNATRules() {
|
||||
break
|
||||
}
|
||||
}
|
||||
deleteNATRule("POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE")
|
||||
deleteNATRule("POSTROUTING", "-s", "192.168.122.0/24", "-o", "eth+", "-j", "MASQUERADE")
|
||||
deleteNATRule("POSTROUTING", "-s", config.LXCNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE")
|
||||
deleteNATRule("POSTROUTING", "-s", config.KVMNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -109,6 +112,8 @@ type Container struct {
|
||||
LXCName string `json:"lxc_name,omitempty"`
|
||||
KVMName string `json:"kvm_name,omitempty"`
|
||||
DiskImage string `json:"disk_image,omitempty"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
StoragePath string `json:"storage_path,omitempty"`
|
||||
MACAddress string `json:"mac_address,omitempty"`
|
||||
Template string `json:"template"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
@@ -128,6 +133,7 @@ type Container struct {
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
Status string `json:"status"`
|
||||
RestoreOnHostBoot bool `json:"restore_on_host_boot,omitempty"`
|
||||
IP string `json:"ip"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
@@ -201,6 +207,323 @@ func (c *Container) UsesLANIPv4() bool {
|
||||
return c.UsesLANDHCP() || c.UsesLANStaticIPv4()
|
||||
}
|
||||
|
||||
func normalizeStoragePools() bool {
|
||||
if AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
result := make([]StoragePool, 0, len(AppConfig.StoragePools))
|
||||
seen := map[string]bool{}
|
||||
defaultSeen := map[string]bool{}
|
||||
for _, pool := range AppConfig.StoragePools {
|
||||
pool.ID = strings.TrimSpace(pool.ID)
|
||||
pool.Name = strings.TrimSpace(pool.Name)
|
||||
pool.Path = filepath.Clean(strings.TrimSpace(pool.Path))
|
||||
pool.MountPoint = filepath.Clean(strings.TrimSpace(pool.MountPoint))
|
||||
if pool.MountPoint == "." {
|
||||
pool.MountPoint = ""
|
||||
}
|
||||
if pool.MountPoint != "" {
|
||||
managedPath := managedStoragePoolPath(pool.MountPoint)
|
||||
if pool.Path != managedPath {
|
||||
pool.Path = managedPath
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if pool.ID == "" {
|
||||
pool.ID = storagePoolIDFromName(pool.Name, pool.Path)
|
||||
changed = true
|
||||
}
|
||||
if pool.Name == "" {
|
||||
pool.Name = pool.ID
|
||||
changed = true
|
||||
}
|
||||
if pool.Path == "." || !filepath.IsAbs(pool.Path) || seen[pool.ID] {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
seen[pool.ID] = true
|
||||
pool.ContentTypes = normalizeStorageContentTypes(pool.ContentTypes)
|
||||
pool.DefaultContents = normalizeStorageContentTypes(pool.DefaultContents)
|
||||
allowed := map[string]bool{}
|
||||
for _, content := range pool.ContentTypes {
|
||||
allowed[content] = true
|
||||
}
|
||||
defaults := make([]string, 0, len(pool.DefaultContents))
|
||||
for _, content := range pool.DefaultContents {
|
||||
if !allowed[content] || defaultSeen[content] {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
defaultSeen[content] = true
|
||||
defaults = append(defaults, content)
|
||||
}
|
||||
pool.DefaultContents = defaults
|
||||
if pool.ContentTypes == nil {
|
||||
pool.ContentTypes = []string{}
|
||||
}
|
||||
result = append(result, pool)
|
||||
}
|
||||
if len(result) != len(AppConfig.StoragePools) {
|
||||
changed = true
|
||||
}
|
||||
AppConfig.StoragePools = result
|
||||
return changed
|
||||
}
|
||||
|
||||
func managedStoragePoolPath(mountPoint string) string {
|
||||
mountPoint = filepath.Clean(strings.TrimSpace(mountPoint))
|
||||
if mountPoint == string(os.PathSeparator) {
|
||||
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
|
||||
}
|
||||
return filepath.Join(mountPoint, "clicd")
|
||||
}
|
||||
|
||||
func storagePoolIDFromName(name, path string) string {
|
||||
base := strings.ToLower(strings.TrimSpace(name))
|
||||
if base == "" {
|
||||
base = filepath.Base(filepath.Clean(path))
|
||||
}
|
||||
replacer := strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-")
|
||||
base = replacer.Replace(base)
|
||||
base = strings.Trim(base, "-")
|
||||
if base == "" {
|
||||
base = "storage"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func normalizeStorageContentTypes(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
valid := map[string]bool{
|
||||
StorageContentLXC: true,
|
||||
StorageContentKVM: true,
|
||||
StorageContentImages: true,
|
||||
StorageContentSnapshots: true,
|
||||
StorageContentBackups: true,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, value := range values {
|
||||
next := strings.ToLower(strings.TrimSpace(value))
|
||||
if !valid[next] || seen[next] {
|
||||
continue
|
||||
}
|
||||
seen[next] = true
|
||||
result = append(result, next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func StoragePoolsForContent(content string) []StoragePool {
|
||||
if AppConfig == nil {
|
||||
return nil
|
||||
}
|
||||
content = strings.ToLower(strings.TrimSpace(content))
|
||||
result := []StoragePool{}
|
||||
for _, pool := range AppConfig.StoragePools {
|
||||
if !pool.Enabled || !storagePoolAllows(pool, content) {
|
||||
continue
|
||||
}
|
||||
result = append(result, pool)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func StoragePoolByID(id string) *StoragePool {
|
||||
if AppConfig == nil {
|
||||
return nil
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
for i := range AppConfig.StoragePools {
|
||||
if AppConfig.StoragePools[i].ID == id {
|
||||
return &AppConfig.StoragePools[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func StoragePoolAllowsContent(pool StoragePool, content string) bool {
|
||||
return storagePoolAllows(pool, strings.ToLower(strings.TrimSpace(content)))
|
||||
}
|
||||
|
||||
func StoragePathForContent(content, fallback string) string {
|
||||
if pool := DefaultStoragePoolForContent(content); pool != nil {
|
||||
return pool.Path
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// PreferredStoragePoolForContent returns the configured default without doing
|
||||
// filesystem probes. Use SelectStoragePoolForContent for new writes.
|
||||
func PreferredStoragePoolForContent(content string) *StoragePool {
|
||||
if AppConfig == nil {
|
||||
return nil
|
||||
}
|
||||
content = strings.ToLower(strings.TrimSpace(content))
|
||||
for i := range AppConfig.StoragePools {
|
||||
pool := &AppConfig.StoragePools[i]
|
||||
if !pool.Enabled || !storagePoolAllows(*pool, content) {
|
||||
continue
|
||||
}
|
||||
for _, item := range pool.DefaultContents {
|
||||
if item == content {
|
||||
return pool
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range AppConfig.StoragePools {
|
||||
pool := &AppConfig.StoragePools[i]
|
||||
if pool.Enabled && storagePoolAllows(*pool, content) {
|
||||
return pool
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultStoragePoolForContent(content string) *StoragePool {
|
||||
pool, _ := SelectStoragePoolForContent(content, "", 0)
|
||||
return pool
|
||||
}
|
||||
|
||||
const storagePoolFreeReserveBytes int64 = 256 * 1024 * 1024
|
||||
|
||||
type storagePoolCandidate struct {
|
||||
pool *StoragePool
|
||||
freeBytes int64
|
||||
isDefault bool
|
||||
}
|
||||
|
||||
// SelectStoragePoolForContent picks a writable mounted pool. The requested or
|
||||
// configured default pool is preferred while it has enough space; remaining
|
||||
// pools are tried by available space from largest to smallest.
|
||||
func SelectStoragePoolForContent(content, requestedPoolID string, requiredBytes int64) (*StoragePool, error) {
|
||||
if AppConfig == nil {
|
||||
return nil, fmt.Errorf("storage configuration is not loaded")
|
||||
}
|
||||
content = strings.ToLower(strings.TrimSpace(content))
|
||||
requestedPoolID = strings.TrimSpace(requestedPoolID)
|
||||
if requiredBytes < 0 {
|
||||
requiredBytes = 0
|
||||
}
|
||||
requiredFree := requiredBytes + storagePoolFreeReserveBytes
|
||||
candidates := make([]storagePoolCandidate, 0, len(AppConfig.StoragePools))
|
||||
configured := 0
|
||||
for i := range AppConfig.StoragePools {
|
||||
pool := &AppConfig.StoragePools[i]
|
||||
if !pool.Enabled || !storagePoolAllows(*pool, content) {
|
||||
continue
|
||||
}
|
||||
configured++
|
||||
freeBytes, available := probeStoragePoolFreeBytes(*pool)
|
||||
if !available {
|
||||
continue
|
||||
}
|
||||
candidate := storagePoolCandidate{pool: pool, freeBytes: freeBytes}
|
||||
for _, item := range pool.DefaultContents {
|
||||
if item == content {
|
||||
candidate.isDefault = true
|
||||
break
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if configured == 0 {
|
||||
return nil, fmt.Errorf("no storage disk is enabled for %s", storageContentLabel(content))
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("all storage disks enabled for %s are unavailable or unmounted", storageContentLabel(content))
|
||||
}
|
||||
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
return candidates[i].freeBytes > candidates[j].freeBytes
|
||||
})
|
||||
preferred := func(match func(storagePoolCandidate) bool) *StoragePool {
|
||||
for _, candidate := range candidates {
|
||||
if match(candidate) && candidate.freeBytes >= requiredFree {
|
||||
return candidate.pool
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if requestedPoolID != "" {
|
||||
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.pool.ID == requestedPoolID }); pool != nil {
|
||||
return pool, nil
|
||||
}
|
||||
}
|
||||
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.isDefault }); pool != nil {
|
||||
return pool, nil
|
||||
}
|
||||
if pool := preferred(func(storagePoolCandidate) bool { return true }); pool != nil {
|
||||
return pool, nil
|
||||
}
|
||||
return nil, fmt.Errorf("storage disks enabled for %s do not have enough free space", storageContentLabel(content))
|
||||
}
|
||||
|
||||
var probeStoragePoolFreeBytes = storagePoolFreeBytes
|
||||
|
||||
func storagePoolFreeBytes(pool StoragePool) (int64, bool) {
|
||||
if strings.TrimSpace(pool.Path) == "" {
|
||||
return 0, false
|
||||
}
|
||||
if _, err := os.Stat(pool.Path); err != nil {
|
||||
if !os.IsNotExist(err) || filepath.Clean(pool.MountPoint) != string(os.PathSeparator) {
|
||||
return 0, false
|
||||
}
|
||||
if err := os.MkdirAll(pool.Path, 0755); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
if mountPoint := strings.TrimSpace(pool.MountPoint); mountPoint != "" {
|
||||
out, err := exec.Command("findmnt", "-n", "-o", "TARGET", "--target", pool.Path).Output()
|
||||
if err != nil || filepath.Clean(strings.TrimSpace(string(out))) != filepath.Clean(mountPoint) {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
out, err := exec.Command("df", "-B1", "-P", pool.Path).Output()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
fields := strings.Fields(lines[len(lines)-1])
|
||||
if len(fields) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
freeBytes, err := strconv.ParseInt(fields[3], 10, 64)
|
||||
return freeBytes, err == nil
|
||||
}
|
||||
|
||||
func storageContentLabel(content string) string {
|
||||
switch content {
|
||||
case StorageContentLXC:
|
||||
return "LXC containers"
|
||||
case StorageContentKVM:
|
||||
return "KVM disks"
|
||||
case StorageContentImages:
|
||||
return "image cache"
|
||||
case StorageContentSnapshots:
|
||||
return "snapshots"
|
||||
case StorageContentBackups:
|
||||
return "backups"
|
||||
default:
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
func storagePoolAllows(pool StoragePool, content string) bool {
|
||||
for _, item := range pool.ContentTypes {
|
||||
if item == content {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Container) NormalizeNetworkAssignments() bool {
|
||||
changed := false
|
||||
lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode))
|
||||
@@ -260,7 +583,7 @@ func (c *Container) NormalizeNetworkAssignments() bool {
|
||||
c.PublicIPv4s = filteredIPv4
|
||||
|
||||
seenIPv6 := map[string]bool{}
|
||||
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1)
|
||||
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses))
|
||||
for _, item := range c.IPv6Addresses {
|
||||
item.Address = strings.TrimSpace(item.Address)
|
||||
item.Interface = strings.TrimSpace(item.Interface)
|
||||
@@ -421,6 +744,43 @@ type SSLConfig struct {
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
StorageContentLXC = "lxc"
|
||||
StorageContentKVM = "kvm"
|
||||
StorageContentImages = "images"
|
||||
StorageContentSnapshots = "snapshots"
|
||||
StorageContentBackups = "backups"
|
||||
)
|
||||
|
||||
type StoragePool struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
MountPoint string `json:"mount_point,omitempty"`
|
||||
ContentTypes []string `json:"content_types"`
|
||||
DefaultContents []string `json:"default_contents,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func defaultPrimaryStoragePool() StoragePool {
|
||||
contents := []string{
|
||||
StorageContentLXC,
|
||||
StorageContentKVM,
|
||||
StorageContentImages,
|
||||
StorageContentSnapshots,
|
||||
StorageContentBackups,
|
||||
}
|
||||
return StoragePool{
|
||||
ID: "disk-root",
|
||||
Name: "system (/)",
|
||||
Path: "/var/lib/clicd",
|
||||
MountPoint: "/",
|
||||
ContentTypes: append([]string(nil), contents...),
|
||||
DefaultContents: append([]string(nil), contents...),
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ClicdConfig is the main configuration structure
|
||||
type ClicdConfig struct {
|
||||
AdminUser string `json:"admin_user"`
|
||||
@@ -434,6 +794,8 @@ type ClicdConfig struct {
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
NATPortStart int `json:"nat_port_start"`
|
||||
NATPortEnd int `json:"nat_port_end"`
|
||||
LXCNATSubnet string `json:"lxc_nat_subnet"`
|
||||
KVMNATSubnet string `json:"kvm_nat_subnet"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
@@ -441,21 +803,65 @@ type ClicdConfig struct {
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
CustomKVMImages []CustomKVMImage `json:"custom_kvm_images"`
|
||||
CustomLXCImages []CustomLXCImage `json:"custom_lxc_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
||||
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||
PanelAccessPolicy PanelAccessPolicy `json:"panel_access_policy"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
TaskConcurrency int `json:"task_concurrency"`
|
||||
Language string `json:"language"`
|
||||
SSL SSLConfig `json:"ssl"`
|
||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||
StoragePools []StoragePool `json:"storage_pools"`
|
||||
}
|
||||
|
||||
const (
|
||||
KVMProvisionerLinuxCloudInit = "linux-cloud-init"
|
||||
KVMProvisionerWindows10 = "windows-10"
|
||||
KVMProvisionerWindows11 = "windows-11"
|
||||
)
|
||||
|
||||
// CustomKVMImage is an administrator-defined KVM image source.
|
||||
type CustomKVMImage struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
URL string `json:"url"`
|
||||
Provisioner string `json:"provisioner"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomLXCImage is an administrator-defined LXC rootfs archive source.
|
||||
type CustomLXCImage struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
var configPath string
|
||||
var AppConfig *ClicdConfig
|
||||
var allocationMu sync.Mutex
|
||||
|
||||
const DefaultSnapshotLimit = 3
|
||||
|
||||
const (
|
||||
DefaultTaskConcurrency = 2
|
||||
MaxTaskConcurrency = 16
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultNATPortStart = 20000
|
||||
DefaultNATPortEnd = 65535
|
||||
@@ -578,6 +984,8 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
NextSSHPort: 22000,
|
||||
NATPortStart: DefaultNATPortStart,
|
||||
NATPortEnd: DefaultNATPortEnd,
|
||||
LXCNATSubnet: configuredSubnetValue("", "CLICD_LXC_SUBNET", DefaultLXCNATSubnet),
|
||||
KVMNATSubnet: configuredSubnetValue("", "CLICD_KVM_SUBNET", DefaultKVMNATSubnet),
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
@@ -587,6 +995,12 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||
WebSSHAllowedOrigins: []string{},
|
||||
PanelAccessPolicy: PanelAccessPolicy{
|
||||
AllowedSources: []string{},
|
||||
TrustedProxies: []string{},
|
||||
},
|
||||
TaskConcurrency: DefaultTaskConcurrency,
|
||||
StoragePools: []StoragePool{defaultPrimaryStoragePool()},
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
@@ -624,10 +1038,17 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
if normalizeNATPortRangeDefaults() {
|
||||
changed = true
|
||||
}
|
||||
if normalizeNATNetworkDefaults() {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextContainerID == 0 {
|
||||
AppConfig.NextContainerID = 1
|
||||
changed = true
|
||||
}
|
||||
if normalized := NormalizeTaskConcurrency(AppConfig.TaskConcurrency); AppConfig.TaskConcurrency != normalized {
|
||||
AppConfig.TaskConcurrency = normalized
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.DataDir == "" {
|
||||
AppConfig.DataDir = dataDir
|
||||
changed = true
|
||||
@@ -655,6 +1076,25 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.WebSSHAllowedOrigins = normalized
|
||||
changed = true
|
||||
}
|
||||
if normalized, err := NormalizePanelAccessPolicy(AppConfig.PanelAccessPolicy); err == nil {
|
||||
if !panelAccessPoliciesEqual(AppConfig.PanelAccessPolicy, normalized) {
|
||||
AppConfig.PanelAccessPolicy = normalized
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
AppConfig.PanelAccessPolicy = PanelAccessPolicy{
|
||||
AllowedSources: []string{},
|
||||
TrustedProxies: []string{},
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if len(AppConfig.StoragePools) == 0 {
|
||||
AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()}
|
||||
changed = true
|
||||
}
|
||||
if normalizeStoragePools() {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.SubUsers == nil {
|
||||
AppConfig.SubUsers = make([]SubUser, 0)
|
||||
changed = true
|
||||
@@ -686,6 +1126,14 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.EnabledImages = make([]string, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.CustomKVMImages == nil {
|
||||
AppConfig.CustomKVMImages = make([]CustomKVMImage, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.CustomLXCImages == nil {
|
||||
AppConfig.CustomLXCImages = make([]CustomLXCImage, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Language == "" {
|
||||
AppConfig.Language = "zh"
|
||||
changed = true
|
||||
@@ -700,6 +1148,16 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeTaskConcurrency(value int) int {
|
||||
if value <= 0 {
|
||||
return DefaultTaskConcurrency
|
||||
}
|
||||
if value > MaxTaskConcurrency {
|
||||
return MaxTaskConcurrency
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func NormalizeLanguage(language string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||
case "en", "en-us", "en_us", "english":
|
||||
@@ -1044,8 +1502,108 @@ func SaveConfig() error {
|
||||
return saveConfigToDB()
|
||||
}
|
||||
|
||||
func ListCustomKVMImages() []CustomKVMImage {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
if AppConfig == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]CustomKVMImage(nil), AppConfig.CustomKVMImages...)
|
||||
}
|
||||
|
||||
func AddCustomKVMImage(image CustomKVMImage) error {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
for _, existing := range AppConfig.CustomKVMImages {
|
||||
if existing.ID == image.ID {
|
||||
return fmt.Errorf("custom KVM image %q already exists", image.ID)
|
||||
}
|
||||
}
|
||||
AppConfig.CustomKVMImages = append(AppConfig.CustomKVMImages, image)
|
||||
if err := SaveConfig(); err != nil {
|
||||
AppConfig.CustomKVMImages = AppConfig.CustomKVMImages[:len(AppConfig.CustomKVMImages)-1]
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveCustomKVMImage(id string) (bool, error) {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
filtered := make([]CustomKVMImage, 0, len(AppConfig.CustomKVMImages))
|
||||
found := false
|
||||
for _, image := range AppConfig.CustomKVMImages {
|
||||
if image.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, image)
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
previous := AppConfig.CustomKVMImages
|
||||
AppConfig.CustomKVMImages = filtered
|
||||
if err := SaveConfig(); err != nil {
|
||||
AppConfig.CustomKVMImages = previous
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ListCustomLXCImages() []CustomLXCImage {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
if AppConfig == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]CustomLXCImage(nil), AppConfig.CustomLXCImages...)
|
||||
}
|
||||
|
||||
func AddCustomLXCImage(image CustomLXCImage) error {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
for _, existing := range AppConfig.CustomLXCImages {
|
||||
if existing.ID == image.ID {
|
||||
return fmt.Errorf("custom LXC image %q already exists", image.ID)
|
||||
}
|
||||
}
|
||||
AppConfig.CustomLXCImages = append(AppConfig.CustomLXCImages, image)
|
||||
if err := SaveConfig(); err != nil {
|
||||
AppConfig.CustomLXCImages = AppConfig.CustomLXCImages[:len(AppConfig.CustomLXCImages)-1]
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveCustomLXCImage(id string) (bool, error) {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
filtered := make([]CustomLXCImage, 0, len(AppConfig.CustomLXCImages))
|
||||
found := false
|
||||
for _, image := range AppConfig.CustomLXCImages {
|
||||
if image.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, image)
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
previous := AppConfig.CustomLXCImages
|
||||
AppConfig.CustomLXCImages = filtered
|
||||
if err := SaveConfig(); err != nil {
|
||||
AppConfig.CustomLXCImages = previous
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AddContainer adds a container to the config
|
||||
func AddContainer(c Container) {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
if c.UUID == "" {
|
||||
c.UUID = NewContainerUUID()
|
||||
}
|
||||
@@ -1057,6 +1615,8 @@ func AddContainer(c Container) {
|
||||
|
||||
// AllocateContainerID allocates a new container ID
|
||||
func AllocateContainerID() int {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
id := AppConfig.NextContainerID
|
||||
AppConfig.NextContainerID++
|
||||
SaveConfig()
|
||||
@@ -1218,6 +1778,23 @@ func UpdateContainerStatus(id int, status string) {
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateContainerStatusAndRestore(id int, status string, restoreOnHostBoot bool) {
|
||||
c := FindContainer(id)
|
||||
if c != nil {
|
||||
c.Status = status
|
||||
c.RestoreOnHostBoot = restoreOnHostBoot
|
||||
SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func SetContainerRestoreOnHostBoot(id int, restore bool) {
|
||||
c := FindContainer(id)
|
||||
if c != nil {
|
||||
c.RestoreOnHostBoot = restore
|
||||
SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
|
||||
c := FindContainer(id)
|
||||
if c == nil {
|
||||
@@ -1316,7 +1893,42 @@ func normalizeNATPortRangeDefaults() bool {
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() (int, error) {
|
||||
return AllocateSSHPortExcluding(nil)
|
||||
}
|
||||
|
||||
// AllocateSSHPortExcluding allocates a management port while reserving
|
||||
// user-requested NAT host ports for the container being created.
|
||||
func AllocateSSHPortExcluding(excluded []int) (int, error) {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
candidate, err := previewSSHPortExcluding(excluded)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
start, end := NATPortRange()
|
||||
AppConfig.NextSSHPort = candidate + 1
|
||||
if AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
SaveConfig()
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
// PreviewSSHPortExcluding returns the management port that the allocator would
|
||||
// choose without advancing or persisting the allocation cursor.
|
||||
func PreviewSSHPortExcluding(excluded []int) (int, error) {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
return previewSSHPortExcluding(excluded)
|
||||
}
|
||||
|
||||
func previewSSHPortExcluding(excluded []int) (int, error) {
|
||||
used := collectAllHostPorts()
|
||||
for _, port := range excluded {
|
||||
if port > 0 {
|
||||
used[port] = true
|
||||
}
|
||||
}
|
||||
start, end := NATPortRange()
|
||||
port := AppConfig.NextSSHPort
|
||||
if port < start || port > end {
|
||||
@@ -1328,11 +1940,6 @@ func AllocateSSHPort() (int, error) {
|
||||
if used[candidate] {
|
||||
continue
|
||||
}
|
||||
AppConfig.NextSSHPort = candidate + 1
|
||||
if AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
SaveConfig()
|
||||
return candidate, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultLXCNATSubnet = "10.0.3.0/24"
|
||||
DefaultKVMNATSubnet = "192.168.122.0/24"
|
||||
)
|
||||
|
||||
type NATNetwork struct {
|
||||
Subnet string `json:"subnet"`
|
||||
Gateway string `json:"gateway"`
|
||||
Netmask string `json:"netmask"`
|
||||
DHCPStart string `json:"dhcp_start"`
|
||||
DHCPEnd string `json:"dhcp_end"`
|
||||
DHCPMax int `json:"dhcp_max"`
|
||||
PrefixBits int `json:"prefix_bits"`
|
||||
}
|
||||
|
||||
func ParseNATNetwork(raw string) (NATNetwork, error) {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(raw))
|
||||
if err != nil || !prefix.Addr().Is4() {
|
||||
return NATNetwork{}, fmt.Errorf("NAT subnet must be a valid IPv4 CIDR")
|
||||
}
|
||||
prefix = prefix.Masked()
|
||||
if prefix.Bits() < 16 || prefix.Bits() > 28 {
|
||||
return NATNetwork{}, fmt.Errorf("NAT subnet prefix must be between /16 and /28")
|
||||
}
|
||||
if !isRFC1918Prefix(prefix) {
|
||||
return NATNetwork{}, fmt.Errorf("NAT subnet must use an RFC1918 private IPv4 range")
|
||||
}
|
||||
|
||||
network := ipv4Uint32(prefix.Addr())
|
||||
hostBits := 32 - prefix.Bits()
|
||||
broadcast := network | uint32((uint64(1)<<hostBits)-1)
|
||||
gateway := uint32IPv4(network + 1)
|
||||
dhcpStart := uint32IPv4(network + 2)
|
||||
dhcpEnd := uint32IPv4(broadcast - 1)
|
||||
return NATNetwork{
|
||||
Subnet: prefix.String(),
|
||||
Gateway: gateway.String(),
|
||||
Netmask: netmaskString(prefix.Bits()),
|
||||
DHCPStart: dhcpStart.String(),
|
||||
DHCPEnd: dhcpEnd.String(),
|
||||
DHCPMax: int(broadcast - network - 2),
|
||||
PrefixBits: prefix.Bits(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func LXCNATNetwork() NATNetwork {
|
||||
return configuredNATNetwork(false)
|
||||
}
|
||||
|
||||
func KVMNATNetwork() NATNetwork {
|
||||
return configuredNATNetwork(true)
|
||||
}
|
||||
|
||||
func normalizeNATNetworkDefaults() bool {
|
||||
changed := false
|
||||
lxcSubnet := configuredSubnetValue(AppConfig.LXCNATSubnet, "CLICD_LXC_SUBNET", DefaultLXCNATSubnet)
|
||||
kvmSubnet := configuredSubnetValue(AppConfig.KVMNATSubnet, "CLICD_KVM_SUBNET", DefaultKVMNATSubnet)
|
||||
if AppConfig.LXCNATSubnet != lxcSubnet {
|
||||
AppConfig.LXCNATSubnet = lxcSubnet
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.KVMNATSubnet != kvmSubnet {
|
||||
AppConfig.KVMNATSubnet = kvmSubnet
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func configuredNATNetwork(kvm bool) NATNetwork {
|
||||
raw := DefaultLXCNATSubnet
|
||||
if kvm {
|
||||
raw = DefaultKVMNATSubnet
|
||||
}
|
||||
if AppConfig != nil {
|
||||
if kvm && AppConfig.KVMNATSubnet != "" {
|
||||
raw = AppConfig.KVMNATSubnet
|
||||
}
|
||||
if !kvm && AppConfig.LXCNATSubnet != "" {
|
||||
raw = AppConfig.LXCNATSubnet
|
||||
}
|
||||
}
|
||||
network, err := ParseNATNetwork(raw)
|
||||
if err == nil {
|
||||
return network
|
||||
}
|
||||
network, _ = ParseNATNetwork(map[bool]string{false: DefaultLXCNATSubnet, true: DefaultKVMNATSubnet}[kvm])
|
||||
return network
|
||||
}
|
||||
|
||||
func configuredSubnetValue(current, envName, fallback string) string {
|
||||
raw := strings.TrimSpace(current)
|
||||
if envValue := strings.TrimSpace(os.Getenv(envName)); envValue != "" {
|
||||
raw = envValue
|
||||
}
|
||||
if network, err := ParseNATNetwork(raw); err == nil {
|
||||
return network.Subnet
|
||||
}
|
||||
network, _ := ParseNATNetwork(fallback)
|
||||
return network.Subnet
|
||||
}
|
||||
|
||||
func isRFC1918Prefix(prefix netip.Prefix) bool {
|
||||
privateRanges := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
}
|
||||
for _, privateRange := range privateRanges {
|
||||
if privateRange.Contains(prefix.Addr()) {
|
||||
last := uint32IPv4(ipv4Uint32(prefix.Addr()) | uint32((uint64(1)<<(32-prefix.Bits()))-1))
|
||||
return privateRange.Contains(last)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ipv4Uint32(addr netip.Addr) uint32 {
|
||||
bytes := addr.As4()
|
||||
return binary.BigEndian.Uint32(bytes[:])
|
||||
}
|
||||
|
||||
func uint32IPv4(value uint32) netip.Addr {
|
||||
var bytes [4]byte
|
||||
binary.BigEndian.PutUint32(bytes[:], value)
|
||||
return netip.AddrFrom4(bytes)
|
||||
}
|
||||
|
||||
func netmaskString(bits int) string {
|
||||
mask := uint32(0)
|
||||
if bits > 0 {
|
||||
mask = ^uint32(0) << (32 - bits)
|
||||
}
|
||||
return uint32IPv4(mask).String()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseNATNetwork(t *testing.T) {
|
||||
network, err := ParseNATNetwork("172.28.40.0/24")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNATNetwork returned error: %v", err)
|
||||
}
|
||||
if network.Subnet != "172.28.40.0/24" ||
|
||||
network.Gateway != "172.28.40.1" ||
|
||||
network.Netmask != "255.255.255.0" ||
|
||||
network.DHCPStart != "172.28.40.2" ||
|
||||
network.DHCPEnd != "172.28.40.254" ||
|
||||
network.DHCPMax != 253 {
|
||||
t.Fatalf("unexpected network values: %+v", network)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNATNetworkMasksHostBits(t *testing.T) {
|
||||
network, err := ParseNATNetwork("10.44.8.99/20")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNATNetwork returned error: %v", err)
|
||||
}
|
||||
if network.Subnet != "10.44.0.0/20" || network.Gateway != "10.44.0.1" || network.DHCPEnd != "10.44.15.254" {
|
||||
t.Fatalf("unexpected masked network values: %+v", network)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNATNetworkRejectsUnsafeRanges(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"203.0.113.0/24",
|
||||
"10.0.0.0/15",
|
||||
"10.0.0.0/29",
|
||||
"not-a-subnet",
|
||||
} {
|
||||
if _, err := ParseNATNetwork(raw); err == nil {
|
||||
t.Fatalf("ParseNATNetwork(%q) unexpectedly succeeded", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNATNetworkDefaultsUsesEnvironment(t *testing.T) {
|
||||
t.Setenv("CLICD_LXC_SUBNET", "172.30.8.0/24")
|
||||
t.Setenv("CLICD_KVM_SUBNET", "10.230.0.0/20")
|
||||
previous := AppConfig
|
||||
AppConfig = &ClicdConfig{}
|
||||
t.Cleanup(func() { AppConfig = previous })
|
||||
|
||||
if !normalizeNATNetworkDefaults() {
|
||||
t.Fatal("expected defaults to change")
|
||||
}
|
||||
if AppConfig.LXCNATSubnet != "172.30.8.0/24" || AppConfig.KVMNATSubnet != "10.230.0.0/20" {
|
||||
t.Fatalf("unexpected configured subnets: LXC=%s KVM=%s", AppConfig.LXCNATSubnet, AppConfig.KVMNATSubnet)
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,45 @@ func TestAllocateSSHPortErrorsWhenConfiguredRangeIsFull(t *testing.T) {
|
||||
t.Fatalf("expected exhausted NAT range error, got port %d", port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateSSHPortExcludingRequestedMappings(t *testing.T) {
|
||||
previous := AppConfig
|
||||
t.Cleanup(func() { AppConfig = previous })
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 32000,
|
||||
NATPortEnd: 32002,
|
||||
NextSSHPort: 32000,
|
||||
}
|
||||
|
||||
port, err := AllocateSSHPortExcluding([]int{32000, 32001})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port != 32002 {
|
||||
t.Fatalf("allocated port = %d, want 32002", port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewSSHPortUsesRangeWithoutAdvancingCursor(t *testing.T) {
|
||||
previous := AppConfig
|
||||
t.Cleanup(func() { AppConfig = previous })
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 35000,
|
||||
NextSSHPort: 30000,
|
||||
Containers: []Container{{
|
||||
PortMappings: []PortMapping{{HostPort: 30000}},
|
||||
}},
|
||||
}
|
||||
|
||||
port, err := PreviewSSHPortExcluding([]int{30001})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port != 30002 {
|
||||
t.Fatalf("preview port = %d, want 30002", port)
|
||||
}
|
||||
if AppConfig.NextSSHPort != 30000 {
|
||||
t.Fatalf("preview advanced cursor to %d", AppConfig.NextSSHPort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PanelAccessPolicy limits access to the complete web panel and API surface.
|
||||
type PanelAccessPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowedSources []string `json:"allowed_sources"`
|
||||
TrustedProxies []string `json:"trusted_proxies"`
|
||||
}
|
||||
|
||||
// ForwardedClientHeaders contains proxy-provided client address headers.
|
||||
type ForwardedClientHeaders struct {
|
||||
ForwardedFor string
|
||||
RealIP string
|
||||
CFConnectingIP string
|
||||
}
|
||||
|
||||
// PanelAccessDecision describes the address used by the access policy.
|
||||
type PanelAccessDecision struct {
|
||||
Allowed bool
|
||||
DirectSource string
|
||||
CurrentSource string
|
||||
UsedForwarded bool
|
||||
}
|
||||
|
||||
func NormalizePanelAccessPolicy(policy PanelAccessPolicy) (PanelAccessPolicy, error) {
|
||||
allowed, err := normalizeIPRanges(policy.AllowedSources, "allowed source")
|
||||
if err != nil {
|
||||
return PanelAccessPolicy{}, err
|
||||
}
|
||||
trusted, err := normalizeIPRanges(policy.TrustedProxies, "trusted proxy")
|
||||
if err != nil {
|
||||
return PanelAccessPolicy{}, err
|
||||
}
|
||||
if policy.Enabled && len(allowed) == 0 {
|
||||
return PanelAccessPolicy{}, fmt.Errorf("at least one allowed IP address or CIDR is required")
|
||||
}
|
||||
return PanelAccessPolicy{
|
||||
Enabled: policy.Enabled,
|
||||
AllowedSources: allowed,
|
||||
TrustedProxies: trusted,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeIPRanges(values []string, label string) ([]string, error) {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, raw := range values {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
normalized, err := normalizeIPRange(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s %q: %w", label, value, err)
|
||||
}
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
result = append(result, normalized)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeIPRange(value string) (string, error) {
|
||||
if strings.Contains(value, "/") {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if prefix.Addr().Zone() != "" {
|
||||
return "", fmt.Errorf("IPv6 zones are not supported")
|
||||
}
|
||||
return prefix.Masked().String(), nil
|
||||
}
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if addr.Zone() != "" {
|
||||
return "", fmt.Errorf("IPv6 zones are not supported")
|
||||
}
|
||||
return addr.Unmap().String(), nil
|
||||
}
|
||||
|
||||
func panelAccessPoliciesEqual(a, b PanelAccessPolicy) bool {
|
||||
return a.Enabled == b.Enabled &&
|
||||
stringSlicesEqual(a.AllowedSources, b.AllowedSources) &&
|
||||
stringSlicesEqual(a.TrustedProxies, b.TrustedProxies)
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EvaluatePanelAccess resolves the effective client address and applies policy.
|
||||
// Forwarded headers are only considered when the TCP peer is trusted.
|
||||
func EvaluatePanelAccess(policy PanelAccessPolicy, remoteAddr string, headers ForwardedClientHeaders) PanelAccessDecision {
|
||||
direct, ok := parseRemoteIP(remoteAddr)
|
||||
decision := PanelAccessDecision{}
|
||||
if ok {
|
||||
decision.DirectSource = direct.String()
|
||||
decision.CurrentSource = direct.String()
|
||||
}
|
||||
if !policy.Enabled {
|
||||
decision.Allowed = true
|
||||
return decision
|
||||
}
|
||||
if !ok {
|
||||
return decision
|
||||
}
|
||||
|
||||
current := direct
|
||||
if ipInRanges(direct, policy.TrustedProxies) {
|
||||
if forwarded, forwardedOK := resolveForwardedIP(direct, policy.TrustedProxies, headers); forwardedOK {
|
||||
current = forwarded
|
||||
decision.CurrentSource = forwarded.String()
|
||||
decision.UsedForwarded = true
|
||||
}
|
||||
}
|
||||
|
||||
// A direct local connection remains an emergency recovery path. When a
|
||||
// trusted local reverse proxy forwards a client address, that client is
|
||||
// still checked normally.
|
||||
if current.IsLoopback() && !decision.UsedForwarded {
|
||||
decision.Allowed = true
|
||||
return decision
|
||||
}
|
||||
decision.Allowed = ipInRanges(current, policy.AllowedSources)
|
||||
return decision
|
||||
}
|
||||
|
||||
func parseRemoteIP(value string) (netip.Addr, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if host, _, err := net.SplitHostPort(value); err == nil {
|
||||
value = host
|
||||
}
|
||||
value = strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[")
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return addr.Unmap(), true
|
||||
}
|
||||
|
||||
func resolveForwardedIP(direct netip.Addr, trusted []string, headers ForwardedClientHeaders) (netip.Addr, bool) {
|
||||
for _, raw := range []string{headers.CFConnectingIP, headers.RealIP} {
|
||||
if addr, ok := parseRemoteIP(strings.TrimSpace(strings.Split(raw, ",")[0])); ok {
|
||||
return addr, true
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(headers.ForwardedFor, ",")
|
||||
current := direct
|
||||
found := false
|
||||
for i := len(parts) - 1; i >= 0 && ipInRanges(current, trusted); i-- {
|
||||
addr, ok := parseRemoteIP(strings.TrimSpace(parts[i]))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
current = addr
|
||||
found = true
|
||||
}
|
||||
return current, found
|
||||
}
|
||||
|
||||
func ipInRanges(addr netip.Addr, ranges []string) bool {
|
||||
addr = addr.Unmap()
|
||||
for _, raw := range ranges {
|
||||
if strings.Contains(raw, "/") {
|
||||
prefix, err := netip.ParsePrefix(raw)
|
||||
if err == nil && prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
candidate, err := netip.ParseAddr(raw)
|
||||
if err == nil && candidate.Unmap() == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePanelAccessPolicy(t *testing.T) {
|
||||
policy, err := NormalizePanelAccessPolicy(PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: []string{" 192.0.2.8 ", "10.20.30.44/24", "192.0.2.8", "2001:db8::1"},
|
||||
TrustedProxies: []string{"127.0.0.1", "2001:db8:1::/64"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePanelAccessPolicy() error = %v", err)
|
||||
}
|
||||
if want := []string{"192.0.2.8", "10.20.30.0/24", "2001:db8::1"}; !reflect.DeepEqual(policy.AllowedSources, want) {
|
||||
t.Fatalf("AllowedSources = %#v, want %#v", policy.AllowedSources, want)
|
||||
}
|
||||
if want := []string{"127.0.0.1", "2001:db8:1::/64"}; !reflect.DeepEqual(policy.TrustedProxies, want) {
|
||||
t.Fatalf("TrustedProxies = %#v, want %#v", policy.TrustedProxies, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePanelAccessPolicyRejectsEmptyEnabledPolicy(t *testing.T) {
|
||||
if _, err := NormalizePanelAccessPolicy(PanelAccessPolicy{Enabled: true}); err == nil {
|
||||
t.Fatal("expected enabled empty policy to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePanelAccess(t *testing.T) {
|
||||
base := PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: []string{"192.0.2.0/24", "2001:db8::/32"},
|
||||
TrustedProxies: []string{"10.0.0.1", "127.0.0.1"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
policy PanelAccessPolicy
|
||||
remote string
|
||||
headers ForwardedClientHeaders
|
||||
allowed bool
|
||||
current string
|
||||
usedForwarded bool
|
||||
}{
|
||||
{
|
||||
name: "disabled",
|
||||
policy: PanelAccessPolicy{},
|
||||
remote: "198.51.100.9:44321",
|
||||
allowed: true,
|
||||
current: "198.51.100.9",
|
||||
},
|
||||
{
|
||||
name: "direct CIDR match",
|
||||
policy: base,
|
||||
remote: "192.0.2.25:44321",
|
||||
allowed: true,
|
||||
current: "192.0.2.25",
|
||||
},
|
||||
{
|
||||
name: "direct denied",
|
||||
policy: base,
|
||||
remote: "198.51.100.9:44321",
|
||||
allowed: false,
|
||||
current: "198.51.100.9",
|
||||
},
|
||||
{
|
||||
name: "spoofed forwarding header ignored",
|
||||
policy: base,
|
||||
remote: "198.51.100.9:44321",
|
||||
headers: ForwardedClientHeaders{
|
||||
ForwardedFor: "192.0.2.10",
|
||||
},
|
||||
allowed: false,
|
||||
current: "198.51.100.9",
|
||||
},
|
||||
{
|
||||
name: "trusted proxy forwards allowed source",
|
||||
policy: base,
|
||||
remote: "10.0.0.1:44321",
|
||||
headers: ForwardedClientHeaders{
|
||||
ForwardedFor: "192.0.2.10",
|
||||
},
|
||||
allowed: true,
|
||||
current: "192.0.2.10",
|
||||
usedForwarded: true,
|
||||
},
|
||||
{
|
||||
name: "trusted proxy forwards denied source",
|
||||
policy: base,
|
||||
remote: "10.0.0.1:44321",
|
||||
headers: ForwardedClientHeaders{
|
||||
RealIP: "198.51.100.20",
|
||||
},
|
||||
allowed: false,
|
||||
current: "198.51.100.20",
|
||||
usedForwarded: true,
|
||||
},
|
||||
{
|
||||
name: "direct loopback recovery",
|
||||
policy: base,
|
||||
remote: "127.0.0.1:44321",
|
||||
allowed: true,
|
||||
current: "127.0.0.1",
|
||||
usedForwarded: false,
|
||||
},
|
||||
{
|
||||
name: "trusted loopback proxy is enforced",
|
||||
policy: base,
|
||||
remote: "127.0.0.1:44321",
|
||||
headers: ForwardedClientHeaders{
|
||||
ForwardedFor: "198.51.100.20",
|
||||
},
|
||||
allowed: false,
|
||||
current: "198.51.100.20",
|
||||
usedForwarded: true,
|
||||
},
|
||||
{
|
||||
name: "IPv6 source",
|
||||
policy: base,
|
||||
remote: "[2001:db8::88]:44321",
|
||||
allowed: true,
|
||||
current: "2001:db8::88",
|
||||
},
|
||||
{
|
||||
name: "trusted proxy chain",
|
||||
policy: base,
|
||||
remote: "10.0.0.1:44321",
|
||||
headers: ForwardedClientHeaders{
|
||||
ForwardedFor: "192.0.2.70, 10.0.0.1",
|
||||
},
|
||||
allowed: true,
|
||||
current: "192.0.2.70",
|
||||
usedForwarded: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := EvaluatePanelAccess(tt.policy, tt.remote, tt.headers)
|
||||
if got.Allowed != tt.allowed || got.CurrentSource != tt.current || got.UsedForwarded != tt.usedForwarded {
|
||||
t.Fatalf("EvaluatePanelAccess() = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeStoragePoolsReplacesPersistedCustomPath(t *testing.T) {
|
||||
previousConfig := AppConfig
|
||||
t.Cleanup(func() { AppConfig = previousConfig })
|
||||
mountPoint := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
|
||||
ID: "data",
|
||||
Name: "data",
|
||||
Path: filepath.Join(t.TempDir(), "uncontrolled"),
|
||||
MountPoint: mountPoint,
|
||||
Enabled: true,
|
||||
}}}
|
||||
if !normalizeStoragePools() {
|
||||
t.Fatal("expected custom path normalization to report a change")
|
||||
}
|
||||
want := managedStoragePoolPath(mountPoint)
|
||||
if got := AppConfig.StoragePools[0].Path; got != want {
|
||||
t.Fatalf("normalized path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectStoragePoolForContent(t *testing.T) {
|
||||
previousConfig := AppConfig
|
||||
previousProbe := probeStoragePoolFreeBytes
|
||||
t.Cleanup(func() {
|
||||
AppConfig = previousConfig
|
||||
probeStoragePoolFreeBytes = previousProbe
|
||||
})
|
||||
|
||||
AppConfig = &ClicdConfig{StoragePools: []StoragePool{
|
||||
{
|
||||
ID: "primary",
|
||||
Path: "/primary",
|
||||
ContentTypes: []string{StorageContentLXC},
|
||||
DefaultContents: []string{StorageContentLXC},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "large",
|
||||
Path: "/large",
|
||||
ContentTypes: []string{StorageContentLXC},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "small",
|
||||
Path: "/small",
|
||||
ContentTypes: []string{StorageContentLXC},
|
||||
Enabled: true,
|
||||
},
|
||||
}}
|
||||
|
||||
free := map[string]int64{
|
||||
"primary": 20 * 1024 * 1024 * 1024,
|
||||
"large": 50 * 1024 * 1024 * 1024,
|
||||
"small": 10 * 1024 * 1024 * 1024,
|
||||
}
|
||||
probeStoragePoolFreeBytes = func(pool StoragePool) (int64, bool) {
|
||||
value, ok := free[pool.ID]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
pool, err := SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pool.ID != "primary" {
|
||||
t.Fatalf("selected %q, want configured default primary", pool.ID)
|
||||
}
|
||||
|
||||
free["primary"] = 128 * 1024 * 1024
|
||||
pool, err = SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pool.ID != "large" {
|
||||
t.Fatalf("selected %q, want largest fallback pool", pool.ID)
|
||||
}
|
||||
|
||||
pool, err = SelectStoragePoolForContent(StorageContentLXC, "small", 5*1024*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pool.ID != "small" {
|
||||
t.Fatalf("selected %q, want requested pool", pool.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectStoragePoolRequiresEnabledContent(t *testing.T) {
|
||||
previousConfig := AppConfig
|
||||
previousProbe := probeStoragePoolFreeBytes
|
||||
t.Cleanup(func() {
|
||||
AppConfig = previousConfig
|
||||
probeStoragePoolFreeBytes = previousProbe
|
||||
})
|
||||
|
||||
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
|
||||
ID: "primary",
|
||||
Path: "/primary",
|
||||
ContentTypes: []string{StorageContentLXC},
|
||||
Enabled: true,
|
||||
}}}
|
||||
probeStoragePoolFreeBytes = func(StoragePool) (int64, bool) { return 100 * 1024 * 1024 * 1024, true }
|
||||
|
||||
if _, err := SelectStoragePoolForContent(StorageContentSnapshots, "", 0); err == nil {
|
||||
t.Fatal("expected snapshots selection to fail when no pool enables snapshots")
|
||||
}
|
||||
}
|
||||
@@ -20,44 +20,47 @@ var (
|
||||
)
|
||||
|
||||
type savedTaskConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
NATPortMappings []PortMapping `json:"nat_port_mappings,omitempty"`
|
||||
ManagementPort int `json:"management_port,omitempty"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||
@@ -190,6 +193,8 @@ func ensureSchema() error {
|
||||
lxc_name TEXT,
|
||||
kvm_name TEXT,
|
||||
disk_image TEXT,
|
||||
storage_pool_id TEXT,
|
||||
storage_path TEXT,
|
||||
mac_address TEXT,
|
||||
template TEXT,
|
||||
vcpu REAL,
|
||||
@@ -209,6 +214,7 @@ func ensureSchema() error {
|
||||
io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT,
|
||||
restore_on_host_boot INTEGER NOT NULL DEFAULT 0,
|
||||
ip TEXT,
|
||||
lan_ipv4_mode TEXT,
|
||||
lan_interface TEXT,
|
||||
@@ -352,6 +358,7 @@ func ensureSchema() error {
|
||||
cfg_io_speed_mbps INTEGER,
|
||||
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_management_port INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_port_mapping_count INTEGER,
|
||||
cfg_assign_nat INTEGER,
|
||||
cfg_lan_ipv4_mode TEXT,
|
||||
@@ -379,6 +386,15 @@ func ensureSchema() error {
|
||||
port INTEGER NOT NULL,
|
||||
PRIMARY KEY (task_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS task_nat_port_mappings (
|
||||
task_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
host_port INTEGER NOT NULL,
|
||||
container_port INTEGER NOT NULL,
|
||||
protocol TEXT,
|
||||
description TEXT,
|
||||
PRIMARY KEY (task_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS login_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT,
|
||||
@@ -429,6 +445,7 @@ func ensureSchemaMigrations() error {
|
||||
{"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_management_port", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_assign_ipv4", "INTEGER"},
|
||||
{"tasks", "cfg_ipv4_count", "INTEGER"},
|
||||
{"tasks", "cfg_public_ipv4s", "TEXT"},
|
||||
@@ -459,6 +476,9 @@ func ensureSchemaMigrations() error {
|
||||
{"containers", "firewall_rules", "TEXT"},
|
||||
{"containers", "allowed_image_ids", "TEXT"},
|
||||
{"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "restore_on_host_boot", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "storage_pool_id", "TEXT"},
|
||||
{"containers", "storage_path", "TEXT"},
|
||||
{"containers", "lan_ipv4_mode", "TEXT"},
|
||||
{"containers", "lan_interface", "TEXT"},
|
||||
{"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
|
||||
@@ -506,13 +526,22 @@ func ensureSchemaMigrations() error {
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE containers
|
||||
SET lan_ipv4_address = COALESCE(lan_ipv4_address, ''),
|
||||
SET lan_ipv4_mode = COALESCE(lan_ipv4_mode, ''),
|
||||
lan_interface = COALESCE(lan_interface, ''),
|
||||
lan_ipv4_address = COALESCE(lan_ipv4_address, ''),
|
||||
lan_ipv4_prefix_len = COALESCE(lan_ipv4_prefix_len, 0),
|
||||
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE containers
|
||||
SET storage_pool_id = COALESCE(storage_pool_id, ''),
|
||||
storage_path = COALESCE(storage_path, '')`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE tasks
|
||||
SET cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''),
|
||||
SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''),
|
||||
cfg_lan_interface = COALESCE(cfg_lan_interface, ''),
|
||||
cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''),
|
||||
cfg_lan_ipv4_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0),
|
||||
cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
|
||||
return err
|
||||
@@ -577,8 +606,11 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
NATPortStart: atoi(meta["nat_port_start"]),
|
||||
NATPortEnd: atoi(meta["nat_port_end"]),
|
||||
LXCNATSubnet: meta["lxc_nat_subnet"],
|
||||
KVMNATSubnet: meta["kvm_nat_subnet"],
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
TaskConcurrency: atoi(meta["task_concurrency"]),
|
||||
Language: meta["language"],
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
||||
@@ -596,6 +628,18 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["panel_access_policy"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.PanelAccessPolicy)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["storage_pools"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.StoragePools)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["custom_kvm_images"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.CustomKVMImages)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["custom_lxc_images"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.CustomLXCImages)
|
||||
}
|
||||
|
||||
if cfg.Containers, err = loadContainers(); err != nil {
|
||||
return nil, false, err
|
||||
@@ -648,6 +692,7 @@ func saveConfigToDB() error {
|
||||
"api_keys",
|
||||
"audit_logs",
|
||||
"task_extra_ports",
|
||||
"task_nat_port_mappings",
|
||||
"tasks",
|
||||
"login_logs",
|
||||
"enabled_images",
|
||||
@@ -695,6 +740,10 @@ func saveMeta(tx *sql.Tx) error {
|
||||
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
||||
panelAccessPolicyJSON, _ := json.Marshal(AppConfig.PanelAccessPolicy)
|
||||
storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools)
|
||||
customKVMImagesJSON, _ := json.Marshal(AppConfig.CustomKVMImages)
|
||||
customLXCImagesJSON, _ := json.Marshal(AppConfig.CustomLXCImages)
|
||||
values := map[string]string{
|
||||
"admin_user": AppConfig.AdminUser,
|
||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||
@@ -706,14 +755,21 @@ func saveMeta(tx *sql.Tx) error {
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"nat_port_start": strconv.Itoa(AppConfig.NATPortStart),
|
||||
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
|
||||
"lxc_nat_subnet": AppConfig.LXCNATSubnet,
|
||||
"kvm_nat_subnet": AppConfig.KVMNATSubnet,
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency),
|
||||
"language": NormalizeLanguage(AppConfig.Language),
|
||||
"ssl": string(sslJSON),
|
||||
"ssl_certificates": string(sslCertificatesJSON),
|
||||
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
||||
"panel_access_policy": string(panelAccessPolicyJSON),
|
||||
"storage_pools": string(storagePoolsJSON),
|
||||
"custom_kvm_images": string(customKVMImagesJSON),
|
||||
"custom_lxc_images": string(customLXCImagesJSON),
|
||||
"schema_version": "1",
|
||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -730,25 +786,25 @@ func saveContainers(tx *sql.Tx) error {
|
||||
NormalizeContainerResourceAliases(&c)
|
||||
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
|
||||
if _, err := tx.Exec(`INSERT INTO containers (
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
||||
io_speed_mbps, io_read_mbps, io_write_mbps,
|
||||
status, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||
status, restore_on_host_boot, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||
ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.StoragePoolID, c.StoragePath, c.MACAddress, c.Template,
|
||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
|
||||
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
|
||||
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
|
||||
c.Status, c.IP, c.LANIPv4Mode, c.LANInterface, c.LANIPv4Address, c.LANIPv4PrefixLen, c.LANIPv4Gateway,
|
||||
c.Status, boolInt(c.RestoreOnHostBoot), c.IP, c.LANIPv4Mode, c.LANInterface, c.LANIPv4Address, c.LANIPv4PrefixLen, c.LANIPv4Gateway,
|
||||
c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
||||
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
|
||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||
@@ -893,17 +949,17 @@ func saveTasksDB(tx *sql.Tx) error {
|
||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_management_port, cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
|
||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_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,
|
||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
|
||||
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps,
|
||||
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.LANIPv4Mode, cfg.LANInterface,
|
||||
cfg.ManagementPort, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.LANIPv4Mode, cfg.LANInterface,
|
||||
cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway, cfg.SnapshotLimit,
|
||||
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
||||
@@ -916,6 +972,14 @@ func saveTasksDB(tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, mapping := range cfg.NATPortMappings {
|
||||
if _, err := tx.Exec(`INSERT INTO task_nat_port_mappings(task_id, position, host_port, container_port, protocol, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, i, mapping.HostPort, mapping.ContainerPort, mapping.Protocol, mapping.Description,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -951,12 +1015,12 @@ func saveSnapshots(tx *sql.Tx) error {
|
||||
|
||||
func loadContainers() ([]Container, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
||||
io_speed_mbps, io_read_mbps, io_write_mbps,
|
||||
status, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||
status, restore_on_host_boot, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||
ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
@@ -972,18 +1036,20 @@ func loadContainers() ([]Container, error) {
|
||||
result := []Container{}
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int
|
||||
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured, restoreOnHostBoot int
|
||||
var firewallDefaultAction string
|
||||
var firewallRulesJSON, allowedImageIDs sql.NullString
|
||||
var storagePoolID, storagePath sql.NullString
|
||||
var lanIPv4Mode, lanInterface sql.NullString
|
||||
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
||||
var lanIPv4PrefixLen sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &storagePoolID, &storagePath, &c.MACAddress, &c.Template,
|
||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
|
||||
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
||||
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
|
||||
&c.Status, &c.IP, &c.LANIPv4Mode, &c.LANInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway,
|
||||
&c.Status, &restoreOnHostBoot, &c.IP, &lanIPv4Mode, &lanInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway,
|
||||
&c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||
@@ -993,12 +1059,17 @@ func loadContainers() ([]Container, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.StoragePoolID = storagePoolID.String
|
||||
c.StoragePath = storagePath.String
|
||||
c.LANIPv4Mode = lanIPv4Mode.String
|
||||
c.LANInterface = lanInterface.String
|
||||
c.LANIPv4Address = lanIPv4Address.String
|
||||
if lanIPv4PrefixLen.Valid {
|
||||
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
||||
}
|
||||
c.LANIPv4Gateway = lanIPv4Gateway.String
|
||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||
c.RestoreOnHostBoot = restoreOnHostBoot != 0
|
||||
c.PolicyBlocked = policyBlocked != 0
|
||||
c.FirewallEnabled = firewallEnabled != 0
|
||||
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
||||
@@ -1208,7 +1279,7 @@ func loadTasks() ([]SavedTask, error) {
|
||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_management_port, cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
|
||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
|
||||
@@ -1232,7 +1303,7 @@ func loadTasks() ([]SavedTask, error) {
|
||||
&cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
|
||||
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
|
||||
&cfg.PortMappingCount, &assignNAT, &lanIPv4Mode, &lanInterface,
|
||||
&cfg.ManagementPort, &cfg.PortMappingCount, &assignNAT, &lanIPv4Mode, &lanInterface,
|
||||
&lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, &cfg.SnapshotLimit,
|
||||
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
||||
&sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt,
|
||||
@@ -1282,6 +1353,10 @@ func loadTasks() ([]SavedTask, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs[i].NATPortMappings, err = loadTaskNATPortMappings(result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].Config = encodeSavedTaskConfig(configs[i])
|
||||
}
|
||||
return result, nil
|
||||
@@ -1304,6 +1379,24 @@ func loadTaskExtraPorts(taskID string) ([]int, error) {
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadTaskNATPortMappings(taskID string) ([]PortMapping, error) {
|
||||
rows, err := db.Query(`SELECT host_port, container_port, protocol, description
|
||||
FROM task_nat_port_mappings WHERE task_id = ? ORDER BY position`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []PortMapping{}
|
||||
for rows.Next() {
|
||||
var mapping PortMapping
|
||||
if err := rows.Scan(&mapping.HostPort, &mapping.ContainerPort, &mapping.Protocol, &mapping.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, mapping)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadLoginLogs() ([]SavedLoginLog, error) {
|
||||
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
|
||||
if err != nil {
|
||||
|
||||
@@ -63,9 +63,37 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
ContainerName: "ct2",
|
||||
Status: "pending",
|
||||
CreatedAt: "2026-06-07 17:29:02",
|
||||
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"assign_ipv6":true}`,
|
||||
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"nat_port_mappings":[{"host_port":30080,"container_port":80,"protocol":"tcp","description":"HTTP"}],"management_port":30022,"assign_ipv6":true}`,
|
||||
}},
|
||||
EnabledImages: []string{"debian-12"},
|
||||
CustomKVMImages: []CustomKVMImage{{
|
||||
ID: "custom-kvm-test",
|
||||
Name: "Test Cloud Image",
|
||||
Description: "third-party image",
|
||||
Distro: "ubuntu",
|
||||
Release: "noble",
|
||||
Arch: "amd64",
|
||||
URL: "https://images.example.test/ubuntu.qcow2",
|
||||
Provisioner: KVMProvisionerLinuxCloudInit,
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
CreatedAt: "2026-07-26 10:00:00",
|
||||
}},
|
||||
CustomLXCImages: []CustomLXCImage{{
|
||||
ID: "custom-lxc-test",
|
||||
Name: "Test Rootfs",
|
||||
Description: "third-party LXC image",
|
||||
Distro: "alpine",
|
||||
Release: "3.21",
|
||||
Arch: "amd64",
|
||||
URL: "https://images.example.test/alpine-rootfs.tar.xz",
|
||||
SHA256: strings.Repeat("b", 64),
|
||||
CreatedAt: "2026-07-26 10:00:00",
|
||||
}},
|
||||
PanelAccessPolicy: PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: []string{"192.0.2.0/24"},
|
||||
TrustedProxies: []string{"127.0.0.1"},
|
||||
},
|
||||
Snapshots: []Snapshot{{
|
||||
ID: "snap-1",
|
||||
ContainerID: 1,
|
||||
@@ -93,11 +121,30 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
|
||||
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
|
||||
}
|
||||
if !strings.Contains(cfg.Tasks[0].Config, `"nat_port_mappings":[{"host_port":30080,"container_port":80`) {
|
||||
t.Fatalf("task NAT mappings were not restored from sqlite: %+v", cfg.Tasks)
|
||||
}
|
||||
if !strings.Contains(cfg.Tasks[0].Config, `"management_port":30022`) {
|
||||
t.Fatalf("task management port was not restored from sqlite: %+v", cfg.Tasks)
|
||||
}
|
||||
if cfg.TaskConcurrency != DefaultTaskConcurrency {
|
||||
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
|
||||
}
|
||||
if !cfg.PanelAccessPolicy.Enabled || len(cfg.PanelAccessPolicy.AllowedSources) != 1 {
|
||||
t.Fatalf("legacy panel access policy was not migrated: %+v", cfg.PanelAccessPolicy)
|
||||
}
|
||||
if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].ID != "custom-kvm-test" {
|
||||
t.Fatalf("legacy custom KVM images were not migrated: %+v", cfg.CustomKVMImages)
|
||||
}
|
||||
if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].ID != "custom-lxc-test" {
|
||||
t.Fatalf("legacy custom LXC images were not migrated: %+v", cfg.CustomLXCImages)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
|
||||
t.Fatalf("sqlite database was not created: %v", err)
|
||||
}
|
||||
|
||||
cfg.Containers[0].Status = "stopped"
|
||||
cfg.TaskConcurrency = 6
|
||||
if err := SaveConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -111,6 +158,18 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
if got := cfg.Containers[0].Status; got != "stopped" {
|
||||
t.Fatalf("expected sqlite value to win after migration, got %q", got)
|
||||
}
|
||||
if got := cfg.TaskConcurrency; got != 6 {
|
||||
t.Fatalf("persisted task concurrency = %d, want 6", got)
|
||||
}
|
||||
if !cfg.PanelAccessPolicy.Enabled || cfg.PanelAccessPolicy.AllowedSources[0] != "192.0.2.0/24" {
|
||||
t.Fatalf("persisted panel access policy = %+v", cfg.PanelAccessPolicy)
|
||||
}
|
||||
if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].SHA256 != strings.Repeat("a", 64) {
|
||||
t.Fatalf("persisted custom KVM images = %+v", cfg.CustomKVMImages)
|
||||
}
|
||||
if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].SHA256 != strings.Repeat("b", 64) {
|
||||
t.Fatalf("persisted custom LXC images = %+v", cfg.CustomLXCImages)
|
||||
}
|
||||
}
|
||||
|
||||
func resetConfigStoreForTest(t *testing.T) {
|
||||
|
||||
+320
-117
@@ -29,6 +29,7 @@ import (
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
"clicd/internal/safehttp"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -95,6 +96,9 @@ var (
|
||||
)
|
||||
|
||||
func BaseDir() string {
|
||||
if pool := config.PreferredStoragePoolForContent(config.StorageContentKVM); pool != nil {
|
||||
return filepath.Join(pool.Path, "kvm")
|
||||
}
|
||||
return "/var/lib/clicd/kvm"
|
||||
}
|
||||
|
||||
@@ -102,11 +106,30 @@ func NewManager() *Manager {
|
||||
return &Manager{BasePath: BaseDir()}
|
||||
}
|
||||
|
||||
func NewManagerForStoragePool(poolID string) *Manager {
|
||||
if pool := config.StoragePoolByID(poolID); pool != nil && pool.Enabled {
|
||||
for _, content := range pool.ContentTypes {
|
||||
if content == config.StorageContentKVM {
|
||||
return &Manager{BasePath: filepath.Join(pool.Path, "kvm")}
|
||||
}
|
||||
}
|
||||
}
|
||||
return NewManager()
|
||||
}
|
||||
|
||||
func (m *Manager) instancesDir() string {
|
||||
return filepath.Join(m.BasePath, "instances")
|
||||
}
|
||||
|
||||
func (m *Manager) instanceDir(name string) string {
|
||||
if config.AppConfig != nil {
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.IsKVM() && c.VirshName() == name && strings.TrimSpace(c.DiskImage) != "" {
|
||||
return filepath.Dir(c.DiskImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filepath.Join(m.instancesDir(), name)
|
||||
}
|
||||
|
||||
@@ -135,10 +158,19 @@ func DownloadImage(image Image) error {
|
||||
}
|
||||
|
||||
func DownloadImageWithProgress(ctx context.Context, image Image, progress DownloadProgressFunc) error {
|
||||
if err := os.MkdirAll(CacheDir(), 0755); err != nil {
|
||||
pool, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := ImagePath(image.ID)
|
||||
cacheDir := filepath.Join(pool.Path, "images", "kvm")
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
ext := ".qcow2"
|
||||
if image.IsWindows() {
|
||||
ext = ".iso"
|
||||
}
|
||||
target := filepath.Join(cacheDir, image.ID+ext)
|
||||
if ok, _ := ImageDownloadedInfo(image.ID); ok {
|
||||
return nil
|
||||
}
|
||||
@@ -153,7 +185,7 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
_ = os.Remove(tmp)
|
||||
if image.Distro == "windows" {
|
||||
if image.IsWindows() {
|
||||
if err := downloadFileWithValidator(ctx, image.URL, tmp, validateWindowsISOResponse(target), progress); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -166,7 +198,13 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if image.Distro == "windows" {
|
||||
if image.SHA256 != "" {
|
||||
if err := verifyFileSHA256(tmp, image.SHA256); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if image.IsWindows() {
|
||||
if err := validateWindowsISO(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -190,6 +228,23 @@ func DownloadImageWithProgress(ctx context.Context, image Image, progress Downlo
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyFileSHA256(path, expected string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
|
||||
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteImage(id string) error {
|
||||
return os.RemoveAll(ImagePath(id))
|
||||
}
|
||||
@@ -201,26 +256,8 @@ func downloadFile(ctx context.Context, url, target string, progress DownloadProg
|
||||
}
|
||||
|
||||
func downloadFileWithValidator(ctx context.Context, url, target string, validate downloadResponseValidator, progress DownloadProgressFunc) error {
|
||||
client := http.Client{
|
||||
Timeout: 30 * time.Minute,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
// Copy User-Agent on redirect
|
||||
if ua := via[0].Header.Get("User-Agent"); ua != "" {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Windows UA needed for Microsoft download servers
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
resp, err := client.Do(req)
|
||||
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
resp, err := safehttp.Get(ctx, url, userAgent, 30*time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -348,6 +385,7 @@ func normalizeQCOW2(ctx context.Context, src, target string) error {
|
||||
|
||||
func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
||||
cfg.NormalizeResourceAliases()
|
||||
cfg.ReportProgress("preparing", "检查 KVM 镜像与创建参数")
|
||||
image := FindImage(cfg.TemplateID)
|
||||
if image == nil {
|
||||
return fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
|
||||
@@ -367,11 +405,24 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
||||
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
|
||||
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
||||
}
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
if IsWindowsImage(image.ID) && cfg.DiskGB < 30 {
|
||||
cfg.DiskGB = 30
|
||||
} else if image.Desktop != "" && cfg.DiskGB < 20 {
|
||||
cfg.DiskGB = 20
|
||||
}
|
||||
cfg.ReportProgress("storage", "选择虚拟机存储磁盘")
|
||||
pool, err := config.SelectStoragePoolForContent(
|
||||
config.StorageContentKVM,
|
||||
cfg.StoragePoolID,
|
||||
int64(cfg.DiskGB)*1024*1024*1024,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.StoragePoolID = pool.ID
|
||||
m = NewManagerForStoragePool(pool.ID)
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
@@ -380,10 +431,19 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
||||
cfg.AllowedImageIDs = []string{cfg.TemplateID}
|
||||
cfg.ImageLimitConfigured = true
|
||||
}
|
||||
managementPort := 0
|
||||
releaseNATReservation := func() {}
|
||||
if cfg.WantsNAT() {
|
||||
managementPort, releaseNATReservation, err = lxc.ReserveCreateNATPorts(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseNATReservation()
|
||||
}
|
||||
|
||||
id := config.AllocateContainerID()
|
||||
vmName := fmt.Sprintf("vm-%d", id)
|
||||
c, err := m.defineContainer(id, vmName, cfg, true)
|
||||
c, err := m.defineContainer(id, vmName, cfg, true, managementPort)
|
||||
if err != nil {
|
||||
_ = m.cleanupVM(vmName)
|
||||
return err
|
||||
@@ -392,7 +452,7 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig, allocatePorts bool) (*config.Container, error) {
|
||||
func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig, allocatePorts bool, managementPort int) (*config.Container, error) {
|
||||
image := FindImage(cfg.TemplateID)
|
||||
if image == nil {
|
||||
return nil, fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
|
||||
@@ -424,6 +484,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
sshPublicKey = sshAccess.PublicKey
|
||||
sshAuthMode = sshAccess.Mode
|
||||
}
|
||||
cfg.ReportProgress("addresses", "分配 IPv4 与 IPv6 地址")
|
||||
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -445,12 +506,17 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
var xml string
|
||||
winAdminPassword := ""
|
||||
if IsWindowsImage(image.ID) {
|
||||
if cfg.RAMMB < 2048 {
|
||||
cfg.RAMMB = 2048
|
||||
minVCPU, minRAMMB, minDiskGB := windowsMinimumResources(image.ID)
|
||||
if cfg.VCPU < minVCPU {
|
||||
cfg.VCPU = minVCPU
|
||||
}
|
||||
if cfg.DiskGB < 30 {
|
||||
cfg.DiskGB = 30
|
||||
if cfg.RAMMB < minRAMMB {
|
||||
cfg.RAMMB = minRAMMB
|
||||
}
|
||||
if cfg.DiskGB < minDiskGB {
|
||||
cfg.DiskGB = minDiskGB
|
||||
}
|
||||
cfg.ReportProgress("disk", "创建 Windows 虚拟磁盘")
|
||||
if err := createEmptyDisk(diskPath, cfg.DiskGB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -459,7 +525,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
}
|
||||
winAdminPassword = generateWindowsPassword()
|
||||
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
|
||||
cfg.ReportProgress("cloud_init", "生成 Windows 自动应答配置")
|
||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List, IsWindows11Image(image.ID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps)
|
||||
@@ -472,9 +539,11 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
cfg.DiskGB = 20
|
||||
}
|
||||
}
|
||||
cfg.ReportProgress("disk", "创建 KVM 系统磁盘")
|
||||
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.ReportProgress("cloud_init", "生成 cloud-init 初始化配置")
|
||||
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -484,6 +553,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.ReportProgress("define", "注册 KVM 虚拟机")
|
||||
cmd := exec.Command("virsh", "define", xmlPath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("virsh define failed: %v, output: %s", err, string(output))
|
||||
@@ -492,9 +562,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
sshPort := 0
|
||||
portMappings := []config.PortMapping{}
|
||||
if allocatePorts && cfg.WantsNAT() {
|
||||
sshPort, err = config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
cfg.ReportProgress("nat", "分配并配置 NAT 端口")
|
||||
sshPort = managementPort
|
||||
if sshPort <= 0 {
|
||||
return nil, fmt.Errorf("NAT management port was not reserved")
|
||||
}
|
||||
if IsWindowsImage(image.ID) {
|
||||
// Windows: RDP (3389) instead of SSH (22)
|
||||
@@ -514,23 +585,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
}
|
||||
}
|
||||
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||
extraPorts := cfg.ExtraPorts
|
||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||
portMappings, err = lxc.SetupCreatePortMappings(tempC, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, port := range extraPorts {
|
||||
if port <= 0 {
|
||||
continue
|
||||
}
|
||||
tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
|
||||
ContainerPort: port,
|
||||
HostPort: port,
|
||||
HostIP: defaultHostIP,
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", port),
|
||||
})
|
||||
}
|
||||
portMappings = tempC.PortMappings
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
@@ -538,6 +596,12 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
if trafficMode == "" {
|
||||
trafficMode = "total"
|
||||
}
|
||||
storagePoolID := cfg.StoragePoolID
|
||||
if storagePoolID == "" {
|
||||
if pool := config.DefaultStoragePoolForContent(config.StorageContentKVM); pool != nil {
|
||||
storagePoolID = pool.ID
|
||||
}
|
||||
}
|
||||
container := &config.Container{
|
||||
ID: id,
|
||||
UUID: config.NewContainerUUID(),
|
||||
@@ -545,6 +609,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
Virtualization: config.VirtualizationKVM,
|
||||
KVMName: vmName,
|
||||
DiskImage: diskPath,
|
||||
StoragePoolID: storagePoolID,
|
||||
StoragePath: m.instanceDir(vmName),
|
||||
MACAddress: mac,
|
||||
Template: cfg.TemplateID,
|
||||
VCPU: cfg.VCPU,
|
||||
@@ -580,6 +646,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
ExpiresAt: cfg.ExpiresAt,
|
||||
}
|
||||
container.NormalizeNetworkAssignments()
|
||||
cfg.ReportProgress("metadata", "保存虚拟机配置")
|
||||
return container, nil
|
||||
}
|
||||
|
||||
@@ -649,7 +716,7 @@ func (m *Manager) StartContainer(id int) error {
|
||||
if !isWindows && c.IP != "" {
|
||||
m.waitForCloudInitReady(name, c.IP, c.SSHPassword)
|
||||
}
|
||||
config.UpdateContainerStatus(id, "running")
|
||||
config.UpdateContainerStatusAndRestore(id, "running", true)
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
if err := m.applyIPv6Runtime(c); err != nil {
|
||||
return err
|
||||
@@ -728,13 +795,13 @@ func (m *Manager) StopContainer(id int) error {
|
||||
name := c.VirshName()
|
||||
status, _ := m.GetContainerStatus(name)
|
||||
if status != "running" {
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
config.UpdateContainerStatusAndRestore(id, "stopped", false)
|
||||
return nil
|
||||
}
|
||||
exec.Command("virsh", "shutdown", name).Run()
|
||||
for i := 0; i < 20; i++ {
|
||||
if status, _ := m.GetContainerStatus(name); status != "running" {
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
config.UpdateContainerStatusAndRestore(id, "stopped", false)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
@@ -743,7 +810,7 @@ func (m *Manager) StopContainer(id int) error {
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("virsh destroy failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
config.UpdateContainerStatusAndRestore(id, "stopped", false)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -822,7 +889,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...lx
|
||||
}
|
||||
cfg.SSHPassword = sshAccess.Password
|
||||
}
|
||||
next, err := m.defineContainer(id, name, cfg, false)
|
||||
next, err := m.defineContainer(id, name, cfg, false, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -954,7 +1021,7 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
|
||||
kvmSnapshotMu.Lock()
|
||||
defer kvmSnapshotMu.Unlock()
|
||||
|
||||
@@ -980,17 +1047,26 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
|
||||
|
||||
name := c.VirshName()
|
||||
instanceDir := m.instanceDir(name)
|
||||
if err := safePathUnder(instanceDir, m.instancesDir()); err != nil {
|
||||
if err := safePathUnder(instanceDir, filepath.Dir(instanceDir)); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
if _, err := os.Stat(instanceDir); err != nil {
|
||||
return config.Snapshot{}, fmt.Errorf("VM storage not found: %v", err)
|
||||
}
|
||||
pool, err := config.SelectStoragePoolForContent(
|
||||
config.StorageContentSnapshots,
|
||||
firstString(storagePoolID),
|
||||
dirSizeBytes(instanceDir),
|
||||
)
|
||||
if err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||
snapshotDir := filepath.Join(snapshotBaseDir(), "kvm", strconv.Itoa(id), snapshotID)
|
||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
||||
baseDir := filepath.Join(pool.Path, "snapshots")
|
||||
snapshotDir := filepath.Join(baseDir, "kvm", strconv.Itoa(id), snapshotID)
|
||||
if err := safePathUnder(snapshotDir, baseDir); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
||||
@@ -1043,7 +1119,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
|
||||
|
||||
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||
if snapshot.Path != "" {
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||
@@ -1065,7 +1141,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
||||
if snapshot.Path == "" {
|
||||
return fmt.Errorf("snapshot path is empty")
|
||||
}
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||
@@ -1081,7 +1157,8 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
||||
}
|
||||
name := c.VirshName()
|
||||
instanceDir := m.instanceDir(name)
|
||||
if err := safePathUnder(instanceDir, m.instancesDir()); err != nil {
|
||||
instanceParent := filepath.Dir(instanceDir)
|
||||
if err := safePathUnder(instanceDir, instanceParent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1089,8 +1166,8 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backupDir := filepath.Join(m.instancesDir(), fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
|
||||
if err := safePathUnder(backupDir, m.instancesDir()); err != nil {
|
||||
backupDir := filepath.Join(instanceParent, fmt.Sprintf(".%s-restore-backup-%d", name, time.Now().UnixNano()))
|
||||
if err := safePathUnder(backupDir, instanceParent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(instanceDir, backupDir); err != nil && !os.IsNotExist(err) {
|
||||
@@ -1110,6 +1187,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
||||
return fmt.Errorf("virsh define failed after restore: %v, output: %s", err, string(output))
|
||||
}
|
||||
c.DiskImage = filepath.Join(instanceDir, "disk.qcow2")
|
||||
c.StoragePath = instanceDir
|
||||
c.Status = "stopped"
|
||||
c.IP = ""
|
||||
config.SaveConfig()
|
||||
@@ -1244,7 +1322,33 @@ func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) tim
|
||||
}
|
||||
|
||||
func snapshotBaseDir() string {
|
||||
return filepath.Join(config.AppConfig.DataDir, "snapshots")
|
||||
return snapshotBaseDirForPool("")
|
||||
}
|
||||
|
||||
func snapshotBaseDirForPool(poolID string) string {
|
||||
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
|
||||
return filepath.Join(pool.Path, "snapshots")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func safeSnapshotPath(path string) error {
|
||||
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
|
||||
return nil
|
||||
}
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
|
||||
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unsafe snapshot path: %s", path)
|
||||
}
|
||||
|
||||
func firstString(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(values[0])
|
||||
}
|
||||
|
||||
func copyTree(src string, dst string) error {
|
||||
@@ -1623,29 +1727,30 @@ func ensureDefaultNetwork() error {
|
||||
// Ensure libvirtd is running
|
||||
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
|
||||
// Non-systemd systems may use a different init, try virsh connect
|
||||
if exec.Command("virsh", "connect").Run() != nil {
|
||||
if virshCLocaleCommand("connect").Run() != nil {
|
||||
return fmt.Errorf("libvirtd is not running and could not be started")
|
||||
}
|
||||
}
|
||||
// Ensure default network is defined
|
||||
if exec.Command("virsh", "net-info", "default").Run() != nil {
|
||||
if virshCLocaleCommand("net-info", "default").Run() != nil {
|
||||
// Default network may not be defined; try to define it
|
||||
netXML := `<network>
|
||||
network := config.KVMNATNetwork()
|
||||
netXML := fmt.Sprintf(`<network>
|
||||
<name>default</name>
|
||||
<bridge name='virbr0'/>
|
||||
<forward mode='nat'/>
|
||||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||||
<ip address='%s' netmask='%s'>
|
||||
<dhcp>
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
<range start='%s' end='%s'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>`
|
||||
</network>`, network.Gateway, network.Netmask, network.DHCPStart, network.DHCPEnd)
|
||||
tmpFile := filepath.Join(os.TempDir(), "clicd-default-net.xml")
|
||||
if err := os.WriteFile(tmpFile, []byte(netXML), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write default network XML: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
if out, err := exec.Command("virsh", "net-define", tmpFile).CombinedOutput(); err != nil {
|
||||
if out, err := virshCLocaleCommand("net-define", tmpFile).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to define libvirt default network: %v, output: %s", err, string(out))
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(libvirtDefaultNetworkMarker), 0755); err == nil {
|
||||
@@ -1653,19 +1758,27 @@ func ensureDefaultNetwork() error {
|
||||
}
|
||||
}
|
||||
// Start and autostart the default network
|
||||
if out, err := exec.Command("virsh", "net-info", "default").Output(); err == nil {
|
||||
if out, err := virshCLocaleCommand("net-info", "default").Output(); err == nil {
|
||||
if !libvirtNetworkActive(string(out)) {
|
||||
if startOut, startErr := exec.Command("virsh", "net-start", "default").CombinedOutput(); startErr != nil {
|
||||
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
|
||||
if startOut, startErr := virshCLocaleCommand("net-start", "default").CombinedOutput(); startErr != nil {
|
||||
if verifyOut, verifyErr := virshCLocaleCommand("net-info", "default").Output(); verifyErr != nil || !libvirtNetworkActive(string(verifyOut)) {
|
||||
return fmt.Errorf("failed to start libvirt default network: %v, output: %s", startErr, string(startOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out, err := exec.Command("virsh", "net-autostart", "default").CombinedOutput(); err != nil {
|
||||
if out, err := virshCLocaleCommand("net-autostart", "default").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to set autostart for libvirt default network: %v, output: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func virshCLocaleCommand(args ...string) *exec.Cmd {
|
||||
cmd := exec.Command("virsh", args...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LC_MESSAGES=C", "LANG=C", "LANGUAGE=C")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func libvirtNetworkActive(info string) bool {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
@@ -1730,7 +1843,7 @@ func createEmptyDisk(target string, diskGB int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string) error {
|
||||
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string, windows11 bool) error {
|
||||
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
||||
if tool == "" {
|
||||
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
||||
@@ -1750,7 +1863,7 @@ func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword)), 0600); err != nil {
|
||||
if err := os.WriteFile(answerPath, []byte(windowsAutounattendXML(hostname, adminPassword, windows11)), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||
@@ -1788,12 +1901,21 @@ func firstAvailableCommand(names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
func windowsAutounattendXML(hostname, adminPassword string, windows11 bool) string {
|
||||
if strings.TrimSpace(hostname) == "" {
|
||||
hostname = "clicd-win"
|
||||
}
|
||||
hostname = sanitizeWindowsComputerName(hostname)
|
||||
setupCommand := `cmd.exe /c if exist C:\CLICD\FirstLogon.ps1 (powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\CLICD\FirstLogon.ps1) else (for %%d in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do @if exist %%d:\FirstLogon.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %%d:\FirstLogon.ps1)`
|
||||
setupCommand := `cmd.exe /c if exist C:\CLICD\FirstLogon.ps1 (powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\CLICD\FirstLogon.ps1) else (for %d in (D E F G H I J K L M N O P Q R S T U V W X Y Z) do @if exist %d:\FirstLogon.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\FirstLogon.ps1)`
|
||||
compatibilityCommands := ""
|
||||
if windows11 {
|
||||
compatibilityCommands = `
|
||||
<RunSynchronous>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>1</Order><Description>Allow virtual TPM compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>2</Order><Description>Allow virtual Secure Boot compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
<RunSynchronousCommand wcm:action="add"><Order>3</Order><Description>Allow virtual CPU compatibility</Description><Path>reg.exe add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
|
||||
</RunSynchronous>`
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||
<settings pass="windowsPE">
|
||||
@@ -1822,7 +1944,7 @@ func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
<AcceptEula>true</AcceptEula>
|
||||
<FullName>CLICD</FullName>
|
||||
<Organization>CLICD</Organization>
|
||||
</UserData>
|
||||
</UserData>%s
|
||||
</component>
|
||||
</settings>
|
||||
<settings pass="specialize">
|
||||
@@ -1843,7 +1965,14 @@ func windowsAutounattendXML(hostname, adminPassword string) string {
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
`, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand))
|
||||
`, compatibilityCommands, xmlEscape(hostname), xmlEscape(adminPassword), xmlEscape(adminPassword), xmlEscape(setupCommand))
|
||||
}
|
||||
|
||||
func windowsMinimumResources(imageID string) (float64, int, int) {
|
||||
if IsWindows11Image(imageID) {
|
||||
return 2, 4096, 64
|
||||
}
|
||||
return 1, 2048, 30
|
||||
}
|
||||
|
||||
func sanitizeWindowsComputerName(name string) string {
|
||||
@@ -2077,7 +2206,7 @@ runcmd:
|
||||
// Build static address block (IPv4 + IPv6)
|
||||
ipv4s = normalizeKVMIPv4List(ipv4s)
|
||||
addressBlock := ""
|
||||
addressLines := make([]string, 0, len(ipv4s)+len(ipv6s))
|
||||
addressLines := make([]string, 0, len(ipv4s))
|
||||
for _, ipv4 := range ipv4s {
|
||||
addressLines = append(addressLines, fmt.Sprintf(" - %s/32", ipv4))
|
||||
}
|
||||
@@ -3363,6 +3492,109 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if !c.IsKVM() {
|
||||
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
|
||||
}
|
||||
|
||||
assignments := []config.PublicIPv4Assignment{}
|
||||
if auto || len(requested) > 0 {
|
||||
allocated, err := lxc.AllocatePublicIPv4Assignments(id, requested, count, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignments = allocated
|
||||
}
|
||||
|
||||
c.PublicIPv4s = assignments
|
||||
reconcileKVMPortMappingHostIPs(c)
|
||||
c.NormalizeNetworkAssignments()
|
||||
config.SaveConfig()
|
||||
|
||||
lxcManager := lxc.NewManager()
|
||||
_ = lxcManager.CleanPortMappings(id)
|
||||
lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||
if c.Status == "running" && c.IP != "" {
|
||||
if err := lxcManager.ApplyPortMappings(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func reconcileKVMPortMappingHostIPs(c *config.Container) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
assigned := map[string]bool{}
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if addr := strings.TrimSpace(item.Address); addr != "" {
|
||||
assigned[addr] = true
|
||||
}
|
||||
}
|
||||
replacement := ""
|
||||
if len(assigned) == 1 {
|
||||
for addr := range assigned {
|
||||
replacement = addr
|
||||
}
|
||||
}
|
||||
for i := range c.PortMappings {
|
||||
hostIP := strings.TrimSpace(c.PortMappings[i].HostIP)
|
||||
if hostIP == "" || assigned[hostIP] {
|
||||
continue
|
||||
}
|
||||
c.PortMappings[i].HostIP = replacement
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if !c.IsKVM() {
|
||||
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
|
||||
}
|
||||
|
||||
old := *c
|
||||
old.IPv6Addresses = append([]config.IPv6Assignment(nil), c.IPv6Addresses...)
|
||||
removeKVMIPv6Runtime(&old)
|
||||
|
||||
assignments := []config.IPv6Assignment{}
|
||||
if auto || len(requested) > 0 {
|
||||
allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto)
|
||||
if err != nil {
|
||||
if old.IPv6 != "" || len(old.IPv6Addresses) > 0 {
|
||||
_ = m.applyIPv6Runtime(&old)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
assignments = allocated
|
||||
}
|
||||
|
||||
c.IPv6 = ""
|
||||
c.IPv6PrefixLen = 0
|
||||
c.IPv6Interface = ""
|
||||
c.IPv6Addresses = assignments
|
||||
c.NormalizeNetworkAssignments()
|
||||
config.SaveConfig()
|
||||
|
||||
if len(c.IPv6Addresses) > 0 {
|
||||
if err := m.applyIPv6Runtime(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if c.Status == "running" {
|
||||
if err := lxc.ApplyFirewallRules(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to re-apply firewall rules after KVM IPv6 removal for %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
|
||||
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||
return nil
|
||||
@@ -3957,35 +4189,6 @@ func sshHostKeyFingerprint(key ssh.PublicKey) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
used := map[int]bool{}
|
||||
// Mark current container's ports
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
used[pm.ContainerPort] = true
|
||||
}
|
||||
// Also mark all other containers' host ports (LXC + KVM)
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
for _, pm := range oc.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
start, end := config.NATPortRange()
|
||||
for next := start; next <= end && len(ports) < count; next++ {
|
||||
if !used[next] {
|
||||
ports = append(ports, next)
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func runStdin(command string, stdin []byte, args ...string) error {
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
|
||||
@@ -3,7 +3,14 @@ package kvm
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
@@ -11,6 +18,84 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestImagePathUsesAllowlistedImageID(t *testing.T) {
|
||||
for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute", "unknown-image"} {
|
||||
if got := filepath.Base(ImagePath(id)); got != "__invalid_image_id__.qcow2" {
|
||||
t.Fatalf("ImagePath(%q) basename = %q", id, got)
|
||||
}
|
||||
}
|
||||
validID := GetImages()[0].ID
|
||||
if got := filepath.Base(ImagePath(validID)); got != validID+".qcow2" {
|
||||
t.Fatalf("ImagePath(%q) basename = %q", validID, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindows11ImageDefinition(t *testing.T) {
|
||||
image := FindImage("kvm-windows-11")
|
||||
if image == nil {
|
||||
t.Fatal("Windows 11 image is missing from the amd64 image list")
|
||||
}
|
||||
if image.Distro != "windows" || image.Release != "11" || image.Arch != "amd64" {
|
||||
t.Fatalf("Windows 11 image metadata = %+v", image)
|
||||
}
|
||||
if !strings.Contains(image.URL, "microsoft.com/fwlink/") {
|
||||
t.Fatalf("Windows 11 image does not use an official Microsoft URL: %s", image.URL)
|
||||
}
|
||||
if got := filepath.Base(ImagePath(image.ID)); got != "kvm-windows-11.iso" {
|
||||
t.Fatalf("Windows 11 image basename = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindows11UnattendAddsCompatibilityChecksOnlyForWindows11(t *testing.T) {
|
||||
windows11 := windowsAutounattendXML("win11-test", "Password123!", true)
|
||||
windows10 := windowsAutounattendXML("win10-test", "Password123!", false)
|
||||
|
||||
for _, key := range []string{"BypassTPMCheck", "BypassSecureBootCheck", "BypassCPUCheck"} {
|
||||
if !strings.Contains(windows11, key) {
|
||||
t.Fatalf("Windows 11 unattend is missing %s", key)
|
||||
}
|
||||
if strings.Contains(windows10, key) {
|
||||
t.Fatalf("Windows 10 unattend unexpectedly contains %s", key)
|
||||
}
|
||||
}
|
||||
var document struct {
|
||||
XMLName xml.Name
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(windows11), &document); err != nil {
|
||||
t.Fatalf("Windows 11 unattend XML is invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsMinimumResources(t *testing.T) {
|
||||
if cpu, ram, disk := windowsMinimumResources("kvm-windows-11"); cpu != 2 || ram != 4096 || disk != 64 {
|
||||
t.Fatalf("Windows 11 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
|
||||
}
|
||||
if cpu, ram, disk := windowsMinimumResources("kvm-windows-10"); cpu != 1 || ram != 2048 || disk != 30 {
|
||||
t.Fatalf("Windows 10 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info string
|
||||
want bool
|
||||
}{
|
||||
{name: "active", info: "Name: default\nActive: yes\n", want: true},
|
||||
{name: "spacing and case", info: " Active : YES \r\n", want: true},
|
||||
{name: "inactive", info: "Name: default\nActive: no\n", want: false},
|
||||
{name: "missing field", info: "Name: default\nAutostart: yes\n", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := libvirtNetworkActive(tc.info); got != tc.want {
|
||||
t.Fatalf("libvirtNetworkActive(%q) = %v, want %v", tc.info, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
|
||||
password := `pa'";$(touch /tmp/pwned); echo #\\word`
|
||||
got, err := chpasswdStdin("root", password)
|
||||
@@ -81,6 +166,77 @@ func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImagesIncludesHostArchitectureCustomImage(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
CustomKVMImages: []config.CustomKVMImage{
|
||||
{
|
||||
ID: "custom-kvm-linux",
|
||||
Name: "Custom Linux",
|
||||
Distro: "ubuntu",
|
||||
Release: "noble",
|
||||
Arch: runtime.GOARCH,
|
||||
URL: "https://example.test/linux.qcow2",
|
||||
Provisioner: config.KVMProvisionerLinuxCloudInit,
|
||||
},
|
||||
{
|
||||
ID: "custom-kvm-other-arch",
|
||||
Name: "Other Architecture",
|
||||
Distro: "ubuntu",
|
||||
Release: "noble",
|
||||
Arch: "not-" + runtime.GOARCH,
|
||||
URL: "https://example.test/other.qcow2",
|
||||
Provisioner: config.KVMProvisionerLinuxCloudInit,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
image := FindImage("custom-kvm-linux")
|
||||
if image == nil || !image.Custom || image.Provisioner != config.KVMProvisionerLinuxCloudInit {
|
||||
t.Fatalf("custom image was not exposed correctly: %+v", image)
|
||||
}
|
||||
if FindImage("custom-kvm-other-arch") != nil {
|
||||
t.Fatal("custom image for another architecture was exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomWindowsProvisionerControlsImageType(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{CustomKVMImages: []config.CustomKVMImage{{
|
||||
ID: "custom-kvm-windows",
|
||||
Name: "Custom Windows",
|
||||
Distro: "windows",
|
||||
Release: "11",
|
||||
Arch: runtime.GOARCH,
|
||||
URL: "https://example.test/windows.iso",
|
||||
Provisioner: config.KVMProvisionerWindows11,
|
||||
}}}
|
||||
|
||||
if !IsWindowsImage("custom-kvm-windows") || !IsWindows11Image("custom-kvm-windows") {
|
||||
t.Fatal("custom Windows 11 provisioner was not recognized")
|
||||
}
|
||||
if ext := filepath.Ext(ImagePath("custom-kvm-windows")); ext != ".iso" {
|
||||
t.Fatalf("custom Windows image extension = %q, want .iso", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFileSHA256(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "image")
|
||||
content := []byte("clicd custom image")
|
||||
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil {
|
||||
t.Fatalf("valid checksum failed: %v", err)
|
||||
}
|
||||
if err := verifyFileSHA256(path, strings.Repeat("0", 64)); err == nil {
|
||||
t.Fatal("invalid checksum unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
|
||||
t.Helper()
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
@@ -14,15 +17,37 @@ type Image struct {
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Desktop string `json:"desktop,omitempty"`
|
||||
Provisioner string `json:"provisioner,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Custom bool `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
func GetImages() []Image {
|
||||
var images []Image
|
||||
switch runtime.GOARCH {
|
||||
case "arm64":
|
||||
return arm64Images()
|
||||
images = arm64Images()
|
||||
default:
|
||||
return amd64Images()
|
||||
images = amd64Images()
|
||||
}
|
||||
for _, custom := range config.ListCustomKVMImages() {
|
||||
if custom.Arch != runtime.GOARCH {
|
||||
continue
|
||||
}
|
||||
images = append(images, Image{
|
||||
ID: custom.ID,
|
||||
Name: custom.Name,
|
||||
Distro: custom.Distro,
|
||||
Release: custom.Release,
|
||||
Arch: custom.Arch,
|
||||
Description: custom.Description,
|
||||
URL: custom.URL,
|
||||
Provisioner: custom.Provisioner,
|
||||
SHA256: custom.SHA256,
|
||||
Custom: true,
|
||||
})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func amd64Images() []Image {
|
||||
@@ -108,6 +133,12 @@ func amd64Images() []Image {
|
||||
Description: "Rocky Linux 9 GenericCloud image for KVM",
|
||||
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-windows-11", Name: "Windows 11 KVM",
|
||||
Distro: "windows", Release: "11", Arch: "amd64",
|
||||
Description: "Windows 11 Enterprise LTSC 2024 Evaluation",
|
||||
URL: "https://go.microsoft.com/fwlink/?clcid=0x409&country=us&culture=en-us&linkid=2289029",
|
||||
},
|
||||
{
|
||||
ID: "kvm-windows-10", Name: "Windows 10 KVM",
|
||||
Distro: "windows", Release: "10", Arch: "amd64",
|
||||
@@ -180,22 +211,56 @@ func FindImage(id string) *Image {
|
||||
}
|
||||
|
||||
func CacheDir() string {
|
||||
if pool := config.PreferredStoragePoolForContent(config.StorageContentImages); pool != nil {
|
||||
return filepath.Join(pool.Path, "images", "kvm")
|
||||
}
|
||||
return filepath.Join(BaseDir(), "images")
|
||||
}
|
||||
|
||||
func ImagePath(id string) string {
|
||||
img := FindImage(id)
|
||||
ext := ".qcow2"
|
||||
if img != nil && img.Distro == "windows" {
|
||||
safeID := "__invalid_image_id__"
|
||||
if img != nil {
|
||||
safeID = img.ID
|
||||
}
|
||||
if img != nil && img.IsWindows() {
|
||||
ext = ".iso"
|
||||
}
|
||||
return filepath.Join(CacheDir(), id+ext)
|
||||
fileName := safeID + ext
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
|
||||
candidate := filepath.Join(pool.Path, "images", "kvm", fileName)
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
legacy := filepath.Join("/var/lib/clicd/kvm/images", fileName)
|
||||
if info, err := os.Stat(legacy); err == nil && !info.IsDir() {
|
||||
return legacy
|
||||
}
|
||||
return filepath.Join(CacheDir(), fileName)
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image distro is "windows".
|
||||
func (image Image) IsWindows() bool {
|
||||
return image.Provisioner == config.KVMProvisionerWindows10 ||
|
||||
image.Provisioner == config.KVMProvisionerWindows11 ||
|
||||
(image.Provisioner == "" && image.Distro == "windows")
|
||||
}
|
||||
|
||||
func (image Image) IsWindows11() bool {
|
||||
return image.Provisioner == config.KVMProvisionerWindows11 ||
|
||||
(image.Provisioner == "" && image.Distro == "windows" && image.Release == "11")
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image uses Windows unattended installation.
|
||||
func IsWindowsImage(id string) bool {
|
||||
img := FindImage(id)
|
||||
return img != nil && img.Distro == "windows"
|
||||
return img != nil && img.IsWindows()
|
||||
}
|
||||
|
||||
func IsWindows11Image(id string) bool {
|
||||
img := FindImage(id)
|
||||
return img != nil && img.IsWindows11()
|
||||
}
|
||||
|
||||
func virtioWinISOPath() string {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/safehttp"
|
||||
)
|
||||
|
||||
type CustomImageDownloadProgress struct {
|
||||
Stage string
|
||||
DownloadedBytes int64
|
||||
TotalBytes int64
|
||||
Percent int
|
||||
}
|
||||
|
||||
type CustomImageDownloadProgressFunc func(CustomImageDownloadProgress)
|
||||
|
||||
func CustomImagePath(id string) string {
|
||||
template := FindTemplate(id)
|
||||
if template == nil || !template.Custom {
|
||||
return filepath.Join("/var/cache/lxc/download/custom", "__invalid_image_id__", "rootfs.tar")
|
||||
}
|
||||
return filepath.Join("/var/cache/lxc/download/custom", template.ID, "rootfs.tar")
|
||||
}
|
||||
|
||||
func CustomImageDownloadedInfo(id string) (bool, int64) {
|
||||
info, err := os.Stat(CustomImagePath(id))
|
||||
if err != nil || info.IsDir() {
|
||||
return false, 0
|
||||
}
|
||||
return true, info.Size()
|
||||
}
|
||||
|
||||
func DeleteCustomImage(id string) error {
|
||||
template := FindTemplate(id)
|
||||
if template == nil || !template.Custom {
|
||||
return fmt.Errorf("custom LXC image not found")
|
||||
}
|
||||
return os.RemoveAll(filepath.Dir(CustomImagePath(id)))
|
||||
}
|
||||
|
||||
func DownloadCustomImageWithProgress(ctx context.Context, template Template, progress CustomImageDownloadProgressFunc) error {
|
||||
if !template.Custom {
|
||||
return fmt.Errorf("template is not a custom LXC image")
|
||||
}
|
||||
target := CustomImagePath(template.ID)
|
||||
if ok, _ := CustomImageDownloadedInfo(template.ID); ok {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
_ = os.Remove(tmp)
|
||||
if err := downloadCustomRootfs(ctx, template.URL, tmp, progress); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if template.SHA256 != "" {
|
||||
if err := verifyCustomRootfsSHA256(tmp, template.SHA256); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if progress != nil {
|
||||
progress(CustomImageDownloadProgress{Stage: "validating", Percent: 100})
|
||||
}
|
||||
if err := ValidateCustomRootfsArchive(tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Chmod(target, 0644)
|
||||
}
|
||||
|
||||
func downloadCustomRootfs(ctx context.Context, sourceURL, target string, progress CustomImageDownloadProgressFunc) error {
|
||||
response, err := safehttp.Get(ctx, sourceURL, "CLICD/1.0 LXC image downloader", 30*time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("download failed: %s", response.Status)
|
||||
}
|
||||
file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
total := response.ContentLength
|
||||
buffer := make([]byte, 128*1024)
|
||||
var downloaded int64
|
||||
for {
|
||||
count, readErr := response.Body.Read(buffer)
|
||||
if count > 0 {
|
||||
if _, err := file.Write(buffer[:count]); err != nil {
|
||||
return err
|
||||
}
|
||||
downloaded += int64(count)
|
||||
if progress != nil {
|
||||
percent := 0
|
||||
if total > 0 {
|
||||
percent = int(downloaded * 100 / total)
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
}
|
||||
progress(CustomImageDownloadProgress{
|
||||
Stage: "downloading",
|
||||
DownloadedBytes: downloaded,
|
||||
TotalBytes: total,
|
||||
Percent: percent,
|
||||
})
|
||||
}
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func verifyCustomRootfsSHA256(filePath, expected string) error {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
|
||||
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateCustomRootfsArchive(archivePath string) error {
|
||||
command := exec.Command("tar", "-tf", archivePath)
|
||||
stdout, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var stderr strings.Builder
|
||||
command.Stderr = &stderr
|
||||
if err := command.Start(); err != nil {
|
||||
return fmt.Errorf("failed to inspect rootfs archive: %v", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
entries := make([]string, 0, 4096)
|
||||
for scanner.Scan() {
|
||||
if len(entries) >= 2_000_000 {
|
||||
_ = command.Process.Kill()
|
||||
return fmt.Errorf("rootfs archive contains too many entries")
|
||||
}
|
||||
entries = append(entries, scanner.Text())
|
||||
}
|
||||
scanErr := scanner.Err()
|
||||
waitErr := command.Wait()
|
||||
if scanErr != nil {
|
||||
return fmt.Errorf("failed to read rootfs archive: %v", scanErr)
|
||||
}
|
||||
if waitErr != nil {
|
||||
return fmt.Errorf("invalid rootfs archive: %v, output: %s", waitErr, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return validateCustomRootfsEntries(entries)
|
||||
}
|
||||
|
||||
func validateCustomRootfsEntries(entries []string) error {
|
||||
hasInit := false
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(strings.ReplaceAll(entry, "\\", "/"))
|
||||
entry = strings.TrimPrefix(entry, "./")
|
||||
if entry == "" || entry == "." {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(entry, "/") {
|
||||
return fmt.Errorf("rootfs archive contains an absolute path: %s", entry)
|
||||
}
|
||||
clean := path.Clean(entry)
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return fmt.Errorf("rootfs archive contains path traversal: %s", entry)
|
||||
}
|
||||
switch strings.TrimSuffix(clean, "/") {
|
||||
case "sbin/init", "usr/lib/systemd/systemd", "lib/systemd/systemd", "bin/busybox", "bin/sh":
|
||||
hasInit = true
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return fmt.Errorf("rootfs archive is empty")
|
||||
}
|
||||
if !hasInit {
|
||||
return fmt.Errorf("rootfs archive does not contain a supported init")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExtractCustomRootfs(templateID, destination string) error {
|
||||
template := FindTemplate(templateID)
|
||||
if template == nil || !template.Custom {
|
||||
return fmt.Errorf("custom LXC image not found: %s", templateID)
|
||||
}
|
||||
archive := CustomImagePath(template.ID)
|
||||
if ok, _ := CustomImageDownloadedInfo(template.ID); !ok {
|
||||
return fmt.Errorf("custom LXC image is not downloaded: %s", templateID)
|
||||
}
|
||||
if err := ValidateCustomRootfsArchive(archive); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(destination, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := exec.Command("tar", "-xpf", archive, "-C", destination).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract custom LXC rootfs: %v, output: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if err := secureExtractedRootfs(destination); err != nil {
|
||||
return err
|
||||
}
|
||||
if !rootfsHasInit(destination) {
|
||||
return fmt.Errorf("extracted custom LXC rootfs is invalid: init not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func secureExtractedRootfs(root string) error {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return nil
|
||||
}
|
||||
target, err := os.Readlink(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resolved string
|
||||
if filepath.IsAbs(target) {
|
||||
resolved = filepath.Join(root, strings.TrimLeft(filepath.ToSlash(target), "/"))
|
||||
relative, err := filepath.Rel(filepath.Dir(filePath), resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(relative, filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
resolved = filepath.Join(filepath.Dir(filePath), target)
|
||||
}
|
||||
relativeToRoot, err := filepath.Rel(root, filepath.Clean(resolved))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("rootfs symlink escapes the archive root: %s -> %s", filePath, target)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestGetTemplatesIncludesHostArchitectureCustomLXCImage(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{CustomLXCImages: []config.CustomLXCImage{
|
||||
{
|
||||
ID: "custom-lxc-host", Name: "Host Rootfs", Distro: "alpine",
|
||||
Release: "3.21", Arch: runtime.GOARCH, URL: "https://example.test/rootfs.tar.xz",
|
||||
},
|
||||
{
|
||||
ID: "custom-lxc-other", Name: "Other Rootfs", Distro: "alpine",
|
||||
Release: "3.21", Arch: "not-" + runtime.GOARCH, URL: "https://example.test/other.tar.xz",
|
||||
},
|
||||
}}
|
||||
|
||||
template := FindTemplate("custom-lxc-host")
|
||||
if template == nil || !template.Custom || template.URL == "" {
|
||||
t.Fatalf("custom LXC template was not exposed correctly: %+v", template)
|
||||
}
|
||||
if FindTemplate("custom-lxc-other") != nil {
|
||||
t.Fatal("custom LXC template for another architecture was exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomImagePathUsesAllowlistedID(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{}
|
||||
|
||||
for _, id := range []string{"", ".", "..", "../../etc/passwd", "/absolute", "unknown"} {
|
||||
got := filepath.ToSlash(CustomImagePath(id))
|
||||
if filepath.Base(filepath.Dir(got)) != "__invalid_image_id__" {
|
||||
t.Fatalf("CustomImagePath(%q) = %q", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCustomRootfsEntries(t *testing.T) {
|
||||
if err := validateCustomRootfsEntries([]string{"./etc/", "./bin/", "./bin/sh"}); err != nil {
|
||||
t.Fatalf("valid rootfs entries failed: %v", err)
|
||||
}
|
||||
for _, entries := range [][]string{
|
||||
{},
|
||||
{"etc/passwd"},
|
||||
{"/etc/passwd", "bin/sh"},
|
||||
{"../../etc/passwd", "bin/sh"},
|
||||
} {
|
||||
if err := validateCustomRootfsEntries(entries); err == nil {
|
||||
t.Fatalf("unsafe rootfs entries unexpectedly passed: %#v", entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1515,6 +1515,75 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
|
||||
oldAssignments := append([]config.IPv6Assignment(nil), c.IPv6Addresses...)
|
||||
oldPrimary := c.IPv6
|
||||
oldPrimaryPrefixLen := c.IPv6PrefixLen
|
||||
oldPrimaryInterface := c.IPv6Interface
|
||||
|
||||
assignments := []config.IPv6Assignment{}
|
||||
if auto || len(requested) > 0 {
|
||||
allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignments = allocated
|
||||
}
|
||||
|
||||
for _, assignment := range oldAssignments {
|
||||
uplink := assignment.Interface
|
||||
if uplink == "" {
|
||||
uplink = oldPrimaryInterface
|
||||
}
|
||||
removeHostIPv6Routing(assignment.Address, uplink)
|
||||
}
|
||||
if len(oldAssignments) == 0 && oldPrimary != "" {
|
||||
removeHostIPv6Routing(oldPrimary, oldPrimaryInterface)
|
||||
oldAssignments = append(oldAssignments, config.IPv6Assignment{Address: oldPrimary, PrefixLen: oldPrimaryPrefixLen, Interface: oldPrimaryInterface})
|
||||
}
|
||||
|
||||
c.IPv6 = ""
|
||||
c.IPv6PrefixLen = 0
|
||||
c.IPv6Interface = ""
|
||||
c.IPv6Addresses = assignments
|
||||
c.NormalizeNetworkAssignments()
|
||||
config.SaveConfig()
|
||||
|
||||
if err := m.applyIPv6Config(c.LxcName(), c.IPv6AddressStrings()...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
|
||||
if _, err := os.Stat(rootfsPath); err == nil {
|
||||
if len(c.IPv6Addresses) == 0 {
|
||||
if err := removeContainerIPv6Init(rootfsPath); err != nil {
|
||||
fmt.Printf("Warning: failed to remove IPv6 init in %s: %v\n", c.LxcName(), err)
|
||||
}
|
||||
} else if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
|
||||
}
|
||||
}
|
||||
status, _ := m.GetContainerStatus(c.LxcName())
|
||||
if status == "running" {
|
||||
m.removeGuestIPv6Addresses(c.LxcName(), oldAssignments)
|
||||
}
|
||||
if len(c.IPv6Addresses) > 0 {
|
||||
if err := m.ApplyIPv6(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if status == "running" {
|
||||
m.removeGuestIPv6DefaultRoute(c.LxcName())
|
||||
if err := ApplyFirewallRules(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 removal for %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
|
||||
configFile := filepath.Join(m.LxcPath, lxcName, "config")
|
||||
data, err := os.ReadFile(configFile)
|
||||
@@ -1522,7 +1591,7 @@ func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
|
||||
return fmt.Errorf("failed to read container config: %v", err)
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
next := make([]string, 0, len(lines)+4)
|
||||
next := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.Contains(trimmed, "# clicd managed: public IPv6") ||
|
||||
@@ -1705,6 +1774,25 @@ exit 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeContainerIPv6Init(rootfsPath string) error {
|
||||
paths := []string{
|
||||
filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init"),
|
||||
filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service"),
|
||||
filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants", "clicd-ipv6.service"),
|
||||
filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6"),
|
||||
filepath.Join(rootfsPath, "etc", "runlevels", "default", "clicd-ipv6"),
|
||||
}
|
||||
for _, level := range []string{"2", "3", "4", "5"} {
|
||||
paths = append(paths, filepath.Join(rootfsPath, "etc", "rc"+level+".d", "S99clicd-ipv6"))
|
||||
}
|
||||
for _, path := range paths {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installContainerIPv6Systemd(rootfsPath string) error {
|
||||
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
|
||||
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
|
||||
@@ -1873,6 +1961,21 @@ func containerIPv6ConnectivityOK(lxcName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) removeGuestIPv6Addresses(lxcName string, assignments []config.IPv6Assignment) {
|
||||
addrs := ipv6AssignmentAddresses(assignments)
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
quoted := shellQuotedIPv6List(addrs)
|
||||
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
|
||||
fmt.Sprintf("for ip in %s; do ip -6 addr del \"$ip/128\" dev eth0 2>/dev/null || true; done", quoted)).Run()
|
||||
}
|
||||
|
||||
func (m *Manager) removeGuestIPv6DefaultRoute(lxcName string) {
|
||||
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
|
||||
fmt.Sprintf("ip -6 route del default via %s dev eth0 2>/dev/null || true", shellQuote(ipv6GatewayLinkLocal))).Run()
|
||||
}
|
||||
|
||||
func ensureIPv6NAT66(ipv6, uplink string) {
|
||||
if ipv6 == "" || uplink == "" {
|
||||
return
|
||||
|
||||
+439
-113
@@ -18,6 +18,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
@@ -227,44 +228,55 @@ func NewManager() *Manager {
|
||||
|
||||
// ContainerConfig defines container creation parameters
|
||||
type ContainerConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
||||
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
||||
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
NATPortMappings []config.PortMapping `json:"nat_port_mappings,omitempty"`
|
||||
ManagementPort int `json:"management_port,omitempty"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Progress func(stage, detail string) `json:"-"`
|
||||
}
|
||||
|
||||
// ReportProgress reports a best-effort creation phase to the task queue.
|
||||
func (cfg ContainerConfig) ReportProgress(stage, detail string) {
|
||||
if cfg.Progress != nil {
|
||||
cfg.Progress(stage, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *ContainerConfig) NormalizeResourceAliases() {
|
||||
@@ -309,6 +321,120 @@ func (cfg ContainerConfig) WantsNAT() bool {
|
||||
return cfg.AssignNAT == nil || *cfg.AssignNAT
|
||||
}
|
||||
|
||||
func (cfg *ContainerConfig) NormalizeCreateNATMappings() error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.WantsNAT() {
|
||||
cfg.ExtraPorts = nil
|
||||
cfg.NATPortMappings = nil
|
||||
cfg.ManagementPort = 0
|
||||
cfg.PortMappingCount = 0
|
||||
return nil
|
||||
}
|
||||
if cfg.ManagementPort < 0 || cfg.ManagementPort > 65535 {
|
||||
return fmt.Errorf("management_port must be 1-65535 or 0 for automatic allocation")
|
||||
}
|
||||
if cfg.ManagementPort > 0 && !config.NATPortInRange(cfg.ManagementPort) {
|
||||
start, end := config.NATPortRange()
|
||||
return fmt.Errorf("management_port must be within configured NAT4 range %d-%d", start, end)
|
||||
}
|
||||
|
||||
mappings := append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||
if len(mappings) == 0 && len(cfg.ExtraPorts) > 0 {
|
||||
mappings = make([]config.PortMapping, 0, len(cfg.ExtraPorts))
|
||||
for _, port := range cfg.ExtraPorts {
|
||||
mappings = append(mappings, config.PortMapping{
|
||||
HostPort: port,
|
||||
ContainerPort: port,
|
||||
Protocol: "tcp",
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(mappings) == 0 {
|
||||
cfg.ExtraPorts = nil
|
||||
if cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(mappings) > 63 {
|
||||
return fmt.Errorf("custom NAT port mappings cannot exceed 63")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
if cfg.ManagementPort > 0 {
|
||||
seen[fmt.Sprintf("%d/tcp", cfg.ManagementPort)] = true
|
||||
}
|
||||
for i := range mappings {
|
||||
pm := &mappings[i]
|
||||
pm.HostIP = strings.TrimSpace(pm.HostIP)
|
||||
if pm.HostIP != "" {
|
||||
return fmt.Errorf("nat_port_mappings[%d].host_ip is not supported during creation", i)
|
||||
}
|
||||
if pm.HostPort < 1 || pm.HostPort > 65535 {
|
||||
return fmt.Errorf("nat_port_mappings[%d].host_port must be 1-65535", i)
|
||||
}
|
||||
if !config.NATPortInRange(pm.HostPort) {
|
||||
start, end := config.NATPortRange()
|
||||
return fmt.Errorf("nat_port_mappings[%d].host_port must be within configured NAT4 range %d-%d", i, start, end)
|
||||
}
|
||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||
return fmt.Errorf("nat_port_mappings[%d].container_port must be 1-65535", i)
|
||||
}
|
||||
pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol))
|
||||
if pm.Protocol == "" {
|
||||
pm.Protocol = "tcp"
|
||||
}
|
||||
if pm.Protocol != "tcp" && pm.Protocol != "udp" {
|
||||
return fmt.Errorf("nat_port_mappings[%d].protocol must be tcp or udp", i)
|
||||
}
|
||||
key := fmt.Sprintf("%d/%s", pm.HostPort, pm.Protocol)
|
||||
if seen[key] {
|
||||
if pm.HostPort == cfg.ManagementPort && pm.Protocol == "tcp" {
|
||||
return fmt.Errorf("NAT host port mapping %s conflicts with management_port", key)
|
||||
}
|
||||
return fmt.Errorf("duplicate NAT host port mapping: %s", key)
|
||||
}
|
||||
seen[key] = true
|
||||
pm.Description = strings.TrimSpace(pm.Description)
|
||||
if pm.Description == "" {
|
||||
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.NATPortMappings = mappings
|
||||
cfg.ExtraPorts = nil
|
||||
cfg.PortMappingCount = len(mappings) + 1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg ContainerConfig) RequestedNATHostPorts() []int {
|
||||
ports := make([]int, 0, len(cfg.NATPortMappings))
|
||||
if cfg.ManagementPort > 0 {
|
||||
ports = append(ports, cfg.ManagementPort)
|
||||
}
|
||||
for _, pm := range cfg.NATPortMappings {
|
||||
if pm.HostPort > 0 {
|
||||
ports = append(ports, pm.HostPort)
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func ValidateCreateNATPortAvailability(cfg ContainerConfig) error {
|
||||
candidate := &config.Container{ID: -1}
|
||||
if cfg.ManagementPort > 0 && !HostPortAvailable(candidate, "", cfg.ManagementPort, "tcp") {
|
||||
return fmt.Errorf("NAT management port %d/tcp is already in use", cfg.ManagementPort)
|
||||
}
|
||||
for _, pm := range cfg.NATPortMappings {
|
||||
if !HostPortAvailable(candidate, "", pm.HostPort, pm.Protocol) {
|
||||
return fmt.Errorf("NAT host port %d/%s is already in use", pm.HostPort, pm.Protocol)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg ContainerConfig) WantsLANDHCP() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(cfg.LANIPv4Mode), config.LANIPv4ModeDHCP)
|
||||
}
|
||||
@@ -324,15 +450,13 @@ func (cfg ContainerConfig) WantsLANIPv4() bool {
|
||||
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
||||
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
cfg.NormalizeResourceAliases()
|
||||
cfg.ReportProgress("preparing", "检查模板与创建参数")
|
||||
tmpl := FindTemplate(cfg.TemplateID)
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
||||
}
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
@@ -352,6 +476,15 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sshPort := 0
|
||||
releaseNATReservation := func() {}
|
||||
if cfg.WantsNAT() {
|
||||
sshPort, releaseNATReservation, err = ReserveCreateNATPorts(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseNATReservation()
|
||||
}
|
||||
|
||||
// Allocate ID and build LXC name
|
||||
id := config.AllocateContainerID()
|
||||
@@ -369,21 +502,46 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
fmt.Printf("Creating LXC container: %s (ID=%d, template: %s/%s/%s)\n",
|
||||
lxcName, id, tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
|
||||
args := []string{"-n", lxcName, "-t", "download", "--",
|
||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
cmd := exec.Command("lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
|
||||
cfg.ReportProgress("rootfs", "下载模板并创建基础文件系统")
|
||||
if tmpl.Custom {
|
||||
output, err := exec.Command("lxc-create", "-n", lxcName, "-t", "none").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lxc-create failed for custom rootfs: %v, output: %s", err, string(output))
|
||||
}
|
||||
if err := m.configureCustomLXCBase(lxcName, tmpl); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
if err := ExtractCustomRootfs(tmpl.ID, filepath.Join(containerDir, "rootfs")); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
args := []string{"-n", lxcName, "-t", "download", "--",
|
||||
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
cmd := exec.Command("lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("lxc-create failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
cfg.ReportProgress("storage", "复制容器数据到存储磁盘")
|
||||
storagePoolID, storagePath, err := m.moveContainerToStoragePool(lxcName, cfg.StoragePoolID)
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.ReportProgress("disk", "创建容量限制磁盘并复制 rootfs")
|
||||
if err := m.applyDiskLimit(lxcName, cfg.DiskGB); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
cfg.ReportProgress("resources", "配置 CPU、内存与网络限制")
|
||||
if cfg.WantsLANIPv4() {
|
||||
iface, err := m.applyLANIPv4Config(lxcName, cfg)
|
||||
if err != nil {
|
||||
@@ -399,6 +557,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.ReportProgress("addresses", "分配 IPv4、IPv6 与 NAT 端口")
|
||||
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
@@ -421,40 +580,17 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
|
||||
sshPassword := sshAccess.Password
|
||||
|
||||
sshPort := 0
|
||||
portMappings := []config.PortMapping{}
|
||||
if cfg.WantsNAT() {
|
||||
sshPort, err = config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup default port mappings (SSH only)
|
||||
portMappings = SetupDefaultPortMappings(sshPort)
|
||||
// NAT4 port mappings should bind to the host IP, not the container's independent public IPv4.
|
||||
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||
|
||||
extraPorts := cfg.ExtraPorts
|
||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||
}
|
||||
for _, containerPort := range extraPorts {
|
||||
if containerPort <= 0 {
|
||||
continue
|
||||
}
|
||||
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
||||
ContainerPort: containerPort,
|
||||
HostPort: containerPort,
|
||||
HostIP: "",
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", containerPort),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tempC.PortMappings = append(tempC.PortMappings, pm)
|
||||
portMappings = tempC.PortMappings
|
||||
portMappings, err = SetupCreatePortMappings(tempC, cfg)
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,6 +608,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
Name: cfg.Name,
|
||||
Virtualization: config.VirtualizationLXC,
|
||||
LXCName: lxcName,
|
||||
StoragePoolID: storagePoolID,
|
||||
StoragePath: storagePath,
|
||||
Template: cfg.TemplateID,
|
||||
VCPU: cfg.VCPU,
|
||||
RAMMB: cfg.RAMMB,
|
||||
@@ -509,18 +647,23 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
ExpiresAt: cfg.ExpiresAt,
|
||||
}
|
||||
container.NormalizeNetworkAssignments()
|
||||
cfg.ReportProgress("metadata", "保存容器配置")
|
||||
config.AddContainer(container)
|
||||
|
||||
// Pre-configure network and SSH in the rootfs before first boot.
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
cfg.ReportProgress("network", "写入容器网络配置")
|
||||
m.preconfigureNetwork(rootfsPath, cfg)
|
||||
if len(ipv6Assignments) > 0 {
|
||||
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
||||
}
|
||||
}
|
||||
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
|
||||
cfg.ReportProgress("ssh", "检测并预配置 SSH 服务")
|
||||
if configured, err := m.preconfigureSSHIfInstalled(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
|
||||
} else if !configured {
|
||||
fmt.Printf("SSH server is not bundled in %s; installation deferred until after first boot\n", lxcName)
|
||||
}
|
||||
if sshAccess.PublicKey != "" {
|
||||
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
|
||||
@@ -530,6 +673,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
cfg.ReportProgress("permissions", "转换非特权容器文件权限")
|
||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
config.RemoveContainer(id)
|
||||
@@ -538,6 +682,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
|
||||
// Set root password AFTER shiftRootfsForUnprivileged,
|
||||
// otherwise /etc/shadow ownership breaks and SSHD cannot authenticate.
|
||||
cfg.ReportProgress("credentials", "设置容器登录凭据")
|
||||
if err := m.setRootfsPassword(rootfsPath, sshPassword); err != nil {
|
||||
fmt.Printf("Warning: failed to set root password in %s: %v\n", lxcName, err)
|
||||
}
|
||||
@@ -546,6 +691,41 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) configureCustomLXCBase(lxcName string, tmpl *Template) error {
|
||||
rootfsPath, err := m.safeRootfsPath(filepath.Join(m.LxcPath, lxcName, "rootfs"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid custom LXC rootfs path: %v", err)
|
||||
}
|
||||
configFile := filepath.Join(filepath.Dir(rootfsPath), "config")
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read custom LXC base config: %v", err)
|
||||
}
|
||||
if _, err := os.Stat("/usr/share/lxc/config/common.conf"); err != nil {
|
||||
return fmt.Errorf("LXC common configuration is unavailable: %v", err)
|
||||
}
|
||||
|
||||
arch := "linux64"
|
||||
switch strings.ToLower(strings.TrimSpace(tmpl.Arch)) {
|
||||
case "amd64", "x86_64", "arm64", "aarch64":
|
||||
default:
|
||||
return fmt.Errorf("unsupported custom LXC architecture: %s", tmpl.Arch)
|
||||
}
|
||||
|
||||
base := []string{
|
||||
"# CLICD custom rootfs base configuration",
|
||||
"lxc.include = /usr/share/lxc/config/common.conf",
|
||||
"lxc.arch = " + arch,
|
||||
"lxc.rootfs.path = dir:" + rootfsPath,
|
||||
"lxc.uts.name = " + lxcName,
|
||||
"",
|
||||
}
|
||||
if err := os.WriteFile(configFile, []byte(strings.Join(base, "\n")+string(data)), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write custom LXC base config: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) preconfigureNetwork(rootfsPath string, cfg ContainerConfig) {
|
||||
templateID := cfg.TemplateID
|
||||
osRelease := ""
|
||||
@@ -677,7 +857,7 @@ func (m *Manager) applyLANIPv4Config(lxcName string, cfg ContainerConfig) (strin
|
||||
values["lxc.net.0.ipv4.gateway"] = strings.TrimSpace(cfg.LANIPv4Gateway)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
next := make([]string, 0, len(lines)+len(values))
|
||||
next := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !cfg.WantsLANStaticIPv4() && (strings.HasPrefix(trimmed, "lxc.net.0.ipv4.address") || strings.HasPrefix(trimmed, "lxc.net.0.ipv4.gateway")) {
|
||||
@@ -869,6 +1049,25 @@ func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode str
|
||||
return nil
|
||||
}
|
||||
|
||||
// preconfigureSSHIfInstalled keeps image creation independent from external
|
||||
// package mirrors. Minimal images install SSH asynchronously after first boot.
|
||||
func (m *Manager) preconfigureSSHIfInstalled(rootfsPath, templateID, sshAuthMode string) (bool, error) {
|
||||
if !rootfsHasSSHD(rootfsPath) {
|
||||
return false, nil
|
||||
}
|
||||
return true, m.preconfigureSSH(rootfsPath, templateID, sshAuthMode)
|
||||
}
|
||||
|
||||
func rootfsHasSSHD(rootfsPath string) bool {
|
||||
for _, relativePath := range []string{"usr/sbin/sshd", "sbin/sshd", "usr/bin/sshd"} {
|
||||
info, err := os.Stat(filepath.Join(rootfsPath, relativePath))
|
||||
if err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyResourceLimits applies cgroup v2 limits and mandatory security hardening to container config.
|
||||
func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error {
|
||||
cfg.NormalizeResourceAliases()
|
||||
@@ -1128,6 +1327,78 @@ func (m *Manager) applyLoopbackDiskLimit(lxcName string, diskGB int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) moveContainerToStoragePool(lxcName string, requestedPoolID string) (string, string, error) {
|
||||
sourceDir := filepath.Join(m.LxcPath, lxcName)
|
||||
requiredBytes := dirSizeBytes(sourceDir)
|
||||
pool, err := config.SelectStoragePoolForContent(config.StorageContentLXC, requestedPoolID, requiredBytes)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
targetRoot := filepath.Join(pool.Path, "lxc")
|
||||
targetDir := filepath.Join(targetRoot, lxcName)
|
||||
sourceAbs, err := filepath.Abs(sourceDir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
targetAbs, err := filepath.Abs(targetDir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if sourceAbs == targetAbs {
|
||||
return pool.ID, targetAbs, nil
|
||||
}
|
||||
if err := os.MkdirAll(targetRoot, 0755); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := os.Lstat(targetDir); err == nil {
|
||||
return "", "", fmt.Errorf("target storage directory already exists: %s", targetDir)
|
||||
}
|
||||
if err := moveLXCStorageDirectory(sourceDir, targetDir); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return pool.ID, targetAbs, nil
|
||||
}
|
||||
|
||||
func moveLXCStorageDirectory(sourceDir, targetDir string) error {
|
||||
if err := os.Rename(sourceDir, targetDir); err == nil {
|
||||
if err := os.Symlink(targetDir, sourceDir); err != nil {
|
||||
_ = os.Rename(targetDir, sourceDir)
|
||||
return fmt.Errorf("failed to create LXC storage symlink: %v", err)
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(err, syscall.EXDEV) {
|
||||
return fmt.Errorf("failed to move LXC container to storage pool: %v", err)
|
||||
}
|
||||
|
||||
if err := copyTree(sourceDir, targetDir); err != nil {
|
||||
_ = os.RemoveAll(targetDir)
|
||||
return fmt.Errorf("failed to copy LXC container to storage pool: %v", err)
|
||||
}
|
||||
backupDir := sourceDir + fmt.Sprintf(".storage-move-%d", time.Now().UnixNano())
|
||||
if err := os.Rename(sourceDir, backupDir); err != nil {
|
||||
_ = os.RemoveAll(targetDir)
|
||||
return fmt.Errorf("failed to finalize LXC storage move: %v", err)
|
||||
}
|
||||
if err := os.Symlink(targetDir, sourceDir); err != nil {
|
||||
_ = os.Rename(backupDir, sourceDir)
|
||||
_ = os.RemoveAll(targetDir)
|
||||
return fmt.Errorf("failed to create LXC storage symlink: %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(backupDir); err != nil {
|
||||
fmt.Printf("Warning: LXC storage moved but source cleanup failed: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storagePoolAllowsContent(pool config.StoragePool, content string) bool {
|
||||
for _, item := range pool.ContentTypes {
|
||||
if item == content {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) ensureDiskImageMounted(lxcName string) error {
|
||||
containerDir := filepath.Join(m.LxcPath, lxcName)
|
||||
rootfsPath := filepath.Join(containerDir, "rootfs")
|
||||
@@ -1183,15 +1454,30 @@ func diskImageMounted(lxcName, rootfsPath string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
targetAbs, err := filepath.Abs(strings.TrimSpace(target))
|
||||
if err != nil {
|
||||
return false
|
||||
return sameFilesystemPath(strings.TrimSpace(target), rootfsPath)
|
||||
}
|
||||
|
||||
func sameFilesystemPath(left, right string) bool {
|
||||
leftInfo, leftErr := os.Stat(left)
|
||||
rightInfo, rightErr := os.Stat(right)
|
||||
if leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) {
|
||||
return true
|
||||
}
|
||||
rootfsAbs, err := filepath.Abs(rootfsPath)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
canonical := func(path string) (string, error) {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(absolute)
|
||||
if err == nil {
|
||||
absolute = resolved
|
||||
}
|
||||
return filepath.Clean(absolute), nil
|
||||
}
|
||||
return targetAbs == rootfsAbs
|
||||
leftPath, leftErr := canonical(left)
|
||||
rightPath, rightErr := canonical(right)
|
||||
return leftErr == nil && rightErr == nil && leftPath == rightPath
|
||||
}
|
||||
|
||||
func applyXFSProjectQuota(rootfsPath, lxcName string, diskGB int) error {
|
||||
@@ -1431,6 +1717,9 @@ func appArmorProfileForTemplate(templateID string) (string, error) {
|
||||
|
||||
func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool {
|
||||
id := strings.ToLower(strings.TrimSpace(templateID))
|
||||
if template := FindTemplate(templateID); template != nil {
|
||||
id += " " + strings.ToLower(template.Distro+" "+template.Release)
|
||||
}
|
||||
if id == "" || strings.Contains(id, "alpine") {
|
||||
return false
|
||||
}
|
||||
@@ -1730,7 +2019,7 @@ func (m *Manager) StartContainer(id int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
config.UpdateContainerStatus(id, "running")
|
||||
config.UpdateContainerStatusAndRestore(id, "running", true)
|
||||
|
||||
var ip string
|
||||
for retry := 0; retry < 10; retry++ {
|
||||
@@ -1761,9 +2050,7 @@ func (m *Manager) StartContainer(id int) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
m.WarmSSHAsync(id, "container start")
|
||||
}
|
||||
|
||||
if current := config.FindContainer(id); current != nil {
|
||||
@@ -1951,7 +2238,7 @@ func (m *Manager) StopContainer(id int) error {
|
||||
|
||||
status, _ := m.GetContainerStatus(lxcName)
|
||||
if status != "running" {
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
config.UpdateContainerStatusAndRestore(id, "stopped", false)
|
||||
m.CleanPortMappings(id)
|
||||
CleanFirewallRules(id)
|
||||
m.cleanupBandwidthLimit(lxcName)
|
||||
@@ -1966,13 +2253,13 @@ func (m *Manager) StopContainer(id int) error {
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if strings.Contains(string(output), "not running") {
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
config.UpdateContainerStatusAndRestore(id, "stopped", false)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to stop container: %v, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
config.UpdateContainerStatusAndRestore(id, "stopped", false)
|
||||
fmt.Printf("Container %d (%s) stopped\n", id, c.Name)
|
||||
return nil
|
||||
}
|
||||
@@ -2330,7 +2617,7 @@ if [ -L /etc/resolv.conf ] 2>/dev/null; then
|
||||
fi
|
||||
# Also try resolvectl for systemd-resolved setups
|
||||
if command -v resolvectl >/dev/null 2>&1; then
|
||||
resolvectl dns eth0 10.0.3.1 2>/dev/null || true
|
||||
resolvectl dns eth0 __CLICD_LXC_GATEWAY__ 2>/dev/null || true
|
||||
resolvectl dns eth0 8.8.8.8 2>/dev/null || true
|
||||
resolvectl domain eth0 '~.' 2>/dev/null || true
|
||||
fi
|
||||
@@ -2338,7 +2625,7 @@ fi
|
||||
# Avoid the trap where systemd stub resolver puts "nameserver 127.0.0.53"
|
||||
# but doesn't actually resolve anything.
|
||||
if ! grep -q '^nameserver [1-9]' /etc/resolv.conf 2>/dev/null; then
|
||||
echo "nameserver 10.0.3.1" > /etc/resolv.conf
|
||||
echo "nameserver __CLICD_LXC_GATEWAY__" > /etc/resolv.conf
|
||||
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
|
||||
fi
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
@@ -2475,6 +2762,7 @@ ensure_sshd_runtime_dir
|
||||
}
|
||||
`
|
||||
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
|
||||
script = strings.ReplaceAll(script, "__CLICD_LXC_GATEWAY__", config.LXCNATNetwork().Gateway)
|
||||
if !startService {
|
||||
return script
|
||||
}
|
||||
@@ -2745,6 +3033,17 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
|
||||
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
var linkedTarget string
|
||||
if info, err := os.Lstat(cleanPath); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
if target, err := os.Readlink(cleanPath); err == nil {
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(cleanPath), target)
|
||||
}
|
||||
if abs, err := filepath.Abs(target); err == nil && lxcStorageTargetAllowed(abs) {
|
||||
linkedTarget = abs
|
||||
}
|
||||
}
|
||||
}
|
||||
exec.Command("lxc-stop", "-n", lxcName, "-k").Run()
|
||||
exec.Command("lxc-destroy", "-n", lxcName, "-f").Run()
|
||||
m.detachContainerMounts(cleanPath)
|
||||
@@ -2756,9 +3055,25 @@ func (m *Manager) cleanupContainerStorage(lxcName string) error {
|
||||
if err := os.RemoveAll(cleanPath); err != nil {
|
||||
return fmt.Errorf("failed to remove container directory %s: %v", cleanPath, err)
|
||||
}
|
||||
if linkedTarget != "" {
|
||||
m.detachContainerMounts(linkedTarget)
|
||||
m.detachContainerLoopDevices(linkedTarget)
|
||||
_ = os.RemoveAll(linkedTarget)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lxcStorageTargetAllowed(path string) bool {
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentLXC) {
|
||||
root := filepath.Join(pool.Path, "lxc")
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err == nil && rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) detachContainerMounts(containerDir string) {
|
||||
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", containerDir).Output()
|
||||
if err != nil {
|
||||
@@ -3047,23 +3362,32 @@ func (m *Manager) replaceRootfsFromTemplate(lxcName string, tmpl *Template) erro
|
||||
}
|
||||
defer m.cleanupTemporaryContainer(tmpName)
|
||||
|
||||
args := []string{
|
||||
"-n", tmpName,
|
||||
"-t", "download",
|
||||
"--",
|
||||
"-d", tmpl.Distro,
|
||||
"-r", tmpl.Release,
|
||||
"-a", tmpl.Arch,
|
||||
}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
output, err := exec.Command("lxc-create", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output))
|
||||
tmpRootfs := filepath.Join(tmpDir, "rootfs")
|
||||
if tmpl.Custom {
|
||||
if err := os.MkdirAll(tmpRootfs, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExtractCustomRootfs(tmpl.ID, tmpRootfs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
args := []string{
|
||||
"-n", tmpName,
|
||||
"-t", "download",
|
||||
"--",
|
||||
"-d", tmpl.Distro,
|
||||
"-r", tmpl.Release,
|
||||
"-a", tmpl.Arch,
|
||||
}
|
||||
if tmpl.Variant != "" {
|
||||
args = append(args, "--variant", tmpl.Variant)
|
||||
}
|
||||
output, err := exec.Command("lxc-create", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download replacement rootfs: %v, output: %s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
tmpRootfs := filepath.Join(tmpDir, "rootfs")
|
||||
if !rootfsHasInit(tmpRootfs) {
|
||||
return fmt.Errorf("downloaded replacement rootfs is invalid: init not found")
|
||||
}
|
||||
@@ -3193,8 +3517,10 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
|
||||
}
|
||||
}
|
||||
c.SSHPassword = sshAccess.Password
|
||||
if err := m.preconfigureSSH(rootfsPath, templateID, sshAccess.Mode); err != nil {
|
||||
if configured, err := m.preconfigureSSHIfInstalled(rootfsPath, templateID, sshAccess.Mode); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
||||
} else if !configured {
|
||||
fmt.Printf("SSH server is not bundled in %s; installation deferred until after reinstall boot\n", lxcName)
|
||||
}
|
||||
if sshAccess.PublicKey != "" {
|
||||
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||
@@ -27,6 +29,335 @@ func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCreateNATMappingsSupportsDifferentHostAndContainerPorts(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||
|
||||
cfg := ContainerConfig{
|
||||
PortMappingCount: 2,
|
||||
NATPortMappings: []config.PortMapping{{
|
||||
HostPort: 30080,
|
||||
ContainerPort: 80,
|
||||
Protocol: "TCP",
|
||||
}},
|
||||
}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.PortMappingCount != 2 || len(cfg.NATPortMappings) != 1 {
|
||||
t.Fatalf("normalized config = %+v", cfg)
|
||||
}
|
||||
mapping := cfg.NATPortMappings[0]
|
||||
if mapping.HostPort != 30080 || mapping.ContainerPort != 80 || mapping.Protocol != "tcp" {
|
||||
t.Fatalf("normalized mapping = %+v", mapping)
|
||||
}
|
||||
|
||||
container := &config.Container{
|
||||
ID: -1,
|
||||
PortMappings: []config.PortMapping{{
|
||||
HostPort: 22000,
|
||||
ContainerPort: 22,
|
||||
Protocol: "tcp",
|
||||
Description: "SSH",
|
||||
}},
|
||||
}
|
||||
mappings, err := SetupCreatePortMappings(container, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mappings) != 2 || mappings[1].HostPort != 30080 || mappings[1].ContainerPort != 80 {
|
||||
t.Fatalf("created mappings = %+v", mappings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCreateNATMappingsKeepsLegacyExtraPortsCompatible(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||
|
||||
cfg := ContainerConfig{ExtraPorts: []int{30080, 30443}}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.ExtraPorts) != 0 || len(cfg.NATPortMappings) != 2 {
|
||||
t.Fatalf("legacy ports were not converted: %+v", cfg)
|
||||
}
|
||||
for _, mapping := range cfg.NATPortMappings {
|
||||
if mapping.HostPort != mapping.ContainerPort {
|
||||
t.Fatalf("legacy mapping changed semantics: %+v", mapping)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCreateNATMappingsRejectsDuplicateHostPort(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||
|
||||
cfg := ContainerConfig{NATPortMappings: []config.PortMapping{
|
||||
{HostPort: 30080, ContainerPort: 80, Protocol: "tcp"},
|
||||
{HostPort: 30080, ContainerPort: 8080, Protocol: "tcp"},
|
||||
}}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err == nil {
|
||||
t.Fatal("duplicate host port was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCreateNATMappingsRejectsManagementPortConflict(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||
|
||||
cfg := ContainerConfig{
|
||||
ManagementPort: 30022,
|
||||
NATPortMappings: []config.PortMapping{{
|
||||
HostPort: 30022,
|
||||
ContainerPort: 8080,
|
||||
Protocol: "tcp",
|
||||
}},
|
||||
}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err == nil || !strings.Contains(err.Error(), "management_port") {
|
||||
t.Fatalf("management port conflict returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaggedRuleLineNumbersReturnsMatchingRulesDescending(t *testing.T) {
|
||||
output := []byte(`Chain PREROUTING (policy ACCEPT)
|
||||
num target prot opt source destination
|
||||
2 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30080 /* clicd-c12-any-30080 */
|
||||
7 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30081 /* clicd-c13-any-30081 */
|
||||
11 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30082 /* clicd-c12-any-30082 */
|
||||
`)
|
||||
got := taggedRuleLineNumbers(output, "clicd-c12-")
|
||||
want := []int{11, 2}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("taggedRuleLineNumbers() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortMappingConntrackDeleteArgs(t *testing.T) {
|
||||
got := portMappingConntrackDeleteArgs(config.PortMapping{
|
||||
HostIP: "203.0.113.10",
|
||||
HostPort: 32022,
|
||||
Protocol: "TCP",
|
||||
})
|
||||
want := []string{"-D", "-p", "tcp", "--dport", "32022", "--dst", "203.0.113.10"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("portMappingConntrackDeleteArgs() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
if got := portMappingConntrackDeleteArgs(config.PortMapping{HostPort: 32022, Protocol: "icmp"}); got != nil {
|
||||
t.Fatalf("unsupported protocol returned args: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSSHPortMappingKeepsIdentityAndSynchronizesSSHPort(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 65535,
|
||||
Containers: []config.Container{{
|
||||
ID: 12,
|
||||
Name: "ct-test",
|
||||
Status: "stopped",
|
||||
SSHPort: 30022,
|
||||
PortMappings: []config.PortMapping{{
|
||||
HostPort: 30022,
|
||||
ContainerPort: 22,
|
||||
Protocol: "tcp",
|
||||
Description: "SSH",
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
manager := NewManager()
|
||||
mappings, err := manager.UpdatePortMapping(12, 0, config.PortMapping{
|
||||
HostPort: 31022,
|
||||
ContainerPort: 22,
|
||||
Protocol: "tcp",
|
||||
Description: "renamed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mappings) != 1 || mappings[0].Description != "SSH" {
|
||||
t.Fatalf("updated mappings = %+v", mappings)
|
||||
}
|
||||
container := config.FindContainer(12)
|
||||
if container == nil || container.SSHPort != 31022 {
|
||||
t.Fatalf("container after SSH update = %+v", container)
|
||||
}
|
||||
if _, err := manager.DeletePortMapping(12, 0); err == nil {
|
||||
t.Fatal("updated SSH mapping became deletable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 20000,
|
||||
NATPortEnd: 65535,
|
||||
NextSSHPort: 22000,
|
||||
}
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
|
||||
cfg := ContainerConfig{NATPortMappings: []config.PortMapping{{
|
||||
HostPort: 22000,
|
||||
ContainerPort: 80,
|
||||
Protocol: "tcp",
|
||||
}}}
|
||||
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
managementPort, release, err := ReserveCreateNATPorts(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if managementPort == 22000 {
|
||||
t.Fatal("management port collided with the requested custom host port")
|
||||
}
|
||||
if _, _, err := ReserveCreateNATPorts(cfg); err == nil {
|
||||
t.Fatal("concurrent task reserved an already reserved custom host port")
|
||||
}
|
||||
|
||||
release()
|
||||
if _, releaseAgain, err := ReserveCreateNATPorts(cfg); err != nil {
|
||||
t.Fatalf("released custom host port remained reserved: %v", err)
|
||||
} else {
|
||||
releaseAgain()
|
||||
}
|
||||
|
||||
explicit := ContainerConfig{ManagementPort: 30022}
|
||||
if err := explicit.NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port, releaseExplicit, err := ReserveCreateNATPorts(explicit); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
defer releaseExplicit()
|
||||
if port != explicit.ManagementPort {
|
||||
t.Fatalf("reserved management port = %d, want %d", port, explicit.ManagementPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveBatchCreateNATPortsPlansAllAutomaticPorts(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 30010,
|
||||
NextSSHPort: 30001,
|
||||
}
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
|
||||
configs := []ContainerConfig{
|
||||
{Name: "batch-1", PortMappingCount: 2},
|
||||
{Name: "batch-2", PortMappingCount: 2},
|
||||
}
|
||||
for i := range configs {
|
||||
if err := configs[i].NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
planned, err := ReserveBatchCreateNATPorts(configs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
used := map[int]string{}
|
||||
for _, cfg := range planned {
|
||||
if cfg.ManagementPort == 0 {
|
||||
t.Fatalf("%s has no planned management port", cfg.Name)
|
||||
}
|
||||
if len(cfg.NATPortMappings) != 1 {
|
||||
t.Fatalf("%s automatic mappings = %d, want 1", cfg.Name, len(cfg.NATPortMappings))
|
||||
}
|
||||
for _, port := range []int{cfg.ManagementPort, cfg.NATPortMappings[0].HostPort} {
|
||||
if owner := used[port]; owner != "" {
|
||||
t.Fatalf("planned port %d is shared by %s and %s", port, owner, cfg.Name)
|
||||
}
|
||||
used[port] = cfg.Name
|
||||
}
|
||||
}
|
||||
|
||||
for _, cfg := range planned {
|
||||
port, release, err := ReserveCreateNATPorts(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("%s could not claim its queued reservation: %v", cfg.Name, err)
|
||||
}
|
||||
if port != cfg.ManagementPort {
|
||||
t.Fatalf("%s claimed management port %d, want %d", cfg.Name, port, cfg.ManagementPort)
|
||||
}
|
||||
release()
|
||||
}
|
||||
if len(queuedCreateNATReservations) != 0 {
|
||||
t.Fatalf("queued reservations remain after claim: %v", queuedCreateNATReservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveBatchCreateNATPortsRejectsWholeConflictingBatch(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
t.Cleanup(func() { config.AppConfig = previous })
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 30010,
|
||||
NextSSHPort: 30001,
|
||||
}
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
createNATReservationMu.Lock()
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
|
||||
configs := []ContainerConfig{
|
||||
{Name: "batch-1", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 80, Protocol: "tcp"}}},
|
||||
{Name: "batch-2", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 8080, Protocol: "tcp"}}},
|
||||
}
|
||||
for i := range configs {
|
||||
if err := configs[i].NormalizeCreateNATMappings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := ReserveBatchCreateNATPorts(configs); err == nil {
|
||||
t.Fatal("conflicting batch was accepted")
|
||||
}
|
||||
if len(queuedCreateNATReservations) != 0 {
|
||||
t.Fatalf("conflicting batch left partial reservations: %v", queuedCreateNATReservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||
@@ -121,6 +452,46 @@ func TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsHasSSHD(t *testing.T) {
|
||||
rootfs := t.TempDir()
|
||||
if rootfsHasSSHD(rootfs) {
|
||||
t.Fatal("empty rootfs unexpectedly reports sshd")
|
||||
}
|
||||
sshd := filepath.Join(rootfs, "usr", "sbin", "sshd")
|
||||
if err := os.MkdirAll(filepath.Dir(sshd), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sshd, []byte("#!/bin/sh\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rootfsHasSSHD(rootfs) {
|
||||
t.Fatal("executable sshd was not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameFilesystemPathResolvesContainerStorageSymlink(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
storageContainer := filepath.Join(base, "storage", "ct-1")
|
||||
rootfs := filepath.Join(storageContainer, "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lxcPath := filepath.Join(base, "lxc")
|
||||
if err := os.MkdirAll(lxcPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
containerLink := filepath.Join(lxcPath, "ct-1")
|
||||
if err := os.Symlink(storageContainer, containerLink); err != nil {
|
||||
t.Skipf("directory symlinks are unavailable: %v", err)
|
||||
}
|
||||
|
||||
linkedRootfs := filepath.Join(containerLink, "rootfs")
|
||||
if !sameFilesystemPath(rootfs, linkedRootfs) {
|
||||
t.Fatalf("sameFilesystemPath(%q, %q) = false, want true", rootfs, linkedRootfs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
|
||||
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
|
||||
|
||||
|
||||
+504
-13
@@ -1,15 +1,26 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
createNATReservationMu sync.Mutex
|
||||
createNATReservationNextID uint64
|
||||
createNATReservations = map[uint64][]config.PortMapping{}
|
||||
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||
)
|
||||
|
||||
// ApplyPortMappings applies iptables DNAT rules for a container's port mappings
|
||||
func (m *Manager) ApplyPortMappings(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
@@ -22,14 +33,16 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||
tag := clicdTag(id)
|
||||
bridge := "lxcbr0"
|
||||
subnet := "10.0.3.0/24"
|
||||
subnet := config.LXCNATNetwork().Subnet
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
subnet = "192.168.122.0/24"
|
||||
subnet = config.KVMNATNetwork().Subnet
|
||||
}
|
||||
|
||||
EnsureForwardRules(bridge)
|
||||
m.CleanPortMappings(id)
|
||||
if err := m.CleanPortMappings(id); err != nil {
|
||||
return fmt.Errorf("clean existing port mappings for container %d: %w", id, err)
|
||||
}
|
||||
deleteBridgeMasquerade(subnet)
|
||||
|
||||
for _, pm := range c.PortMappings {
|
||||
@@ -236,9 +249,13 @@ func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||
|
||||
func EnsureAllRunningPortMappings() {
|
||||
m := NewManager()
|
||||
m.cleanOrphanedPortMappings()
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
|
||||
if err := m.CleanPortMappings(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to clean inactive port mappings for %s: %v\n", c.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := m.ApplyPortMappings(c.ID); err != nil {
|
||||
@@ -247,6 +264,33 @@ func EnsureAllRunningPortMappings() {
|
||||
}
|
||||
}
|
||||
|
||||
var taggedContainerIDPattern = regexp.MustCompile(`clicd-c([0-9]+)-`)
|
||||
|
||||
func (m *Manager) cleanOrphanedPortMappings() {
|
||||
output, err := exec.Command("iptables-save").Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
configured := make(map[int]bool, len(config.AppConfig.Containers))
|
||||
for i := range config.AppConfig.Containers {
|
||||
configured[config.AppConfig.Containers[i].ID] = true
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, match := range taggedContainerIDPattern.FindAllSubmatch(output, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.Atoi(string(match[1]))
|
||||
if err != nil || configured[id] || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
if err := m.CleanPortMappings(id); err != nil {
|
||||
fmt.Printf("Warning: failed to clean orphaned port mappings for container %d: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||
func EnsureForwardRules(bridge string) {
|
||||
if bridge == "" {
|
||||
@@ -299,16 +343,108 @@ func ensureLibvirtForwardRules(bridge string) {
|
||||
|
||||
// CleanPortMappings removes all iptables rules for a container
|
||||
func (m *Manager) CleanPortMappings(id int) error {
|
||||
tag := clicdTag(id)
|
||||
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain))
|
||||
cmd.Run()
|
||||
marker := "clicd-" + clicdTag(id) + "-"
|
||||
var cleanupErrors []error
|
||||
for _, target := range []struct {
|
||||
table string
|
||||
chain string
|
||||
}{
|
||||
{table: "nat", chain: "PREROUTING"},
|
||||
{table: "nat", chain: "POSTROUTING"},
|
||||
{chain: "FORWARD"},
|
||||
} {
|
||||
if err := deleteTaggedIPTablesRules(target.table, target.chain, marker); err != nil {
|
||||
cleanupErrors = append(cleanupErrors, err)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||
cmd.Run()
|
||||
return nil
|
||||
if c := config.FindContainer(id); c != nil {
|
||||
for _, mapping := range c.PortMappings {
|
||||
clearPortMappingConntrack(mapping)
|
||||
}
|
||||
}
|
||||
return errors.Join(cleanupErrors...)
|
||||
}
|
||||
|
||||
func deleteTaggedIPTablesRules(table, chain, marker string) error {
|
||||
listArgs := []string{"-w", "5"}
|
||||
if table != "" {
|
||||
listArgs = append(listArgs, "-t", table)
|
||||
}
|
||||
listArgs = append(listArgs, "-L", chain, "-n", "--line-numbers")
|
||||
output, err := exec.Command("iptables", listArgs...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list iptables %s/%s: %w: %s", tableName(table), chain, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
var deleteErrors []error
|
||||
for _, lineNumber := range taggedRuleLineNumbers(output, marker) {
|
||||
deleteArgs := []string{"-w", "5"}
|
||||
if table != "" {
|
||||
deleteArgs = append(deleteArgs, "-t", table)
|
||||
}
|
||||
deleteArgs = append(deleteArgs, "-D", chain, strconv.Itoa(lineNumber))
|
||||
if output, err := exec.Command("iptables", deleteArgs...).CombinedOutput(); err != nil {
|
||||
deleteErrors = append(deleteErrors, fmt.Errorf(
|
||||
"delete iptables %s/%s rule %d: %w: %s",
|
||||
tableName(table), chain, lineNumber, err, strings.TrimSpace(string(output)),
|
||||
))
|
||||
}
|
||||
}
|
||||
return errors.Join(deleteErrors...)
|
||||
}
|
||||
|
||||
func taggedRuleLineNumbers(output []byte, marker string) []int {
|
||||
lineNumbers := make([]int, 0)
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
if !strings.Contains(line, marker) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
lineNumber, err := strconv.Atoi(fields[0])
|
||||
if err == nil && lineNumber > 0 {
|
||||
lineNumbers = append(lineNumbers, lineNumber)
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(lineNumbers)))
|
||||
return lineNumbers
|
||||
}
|
||||
|
||||
func tableName(table string) string {
|
||||
if table == "" {
|
||||
return "filter"
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
func clearPortMappingConntrack(mapping config.PortMapping) {
|
||||
args := portMappingConntrackDeleteArgs(mapping)
|
||||
if len(args) == 0 {
|
||||
return
|
||||
}
|
||||
// conntrack exits non-zero when no matching flow exists; that is already clean.
|
||||
_ = exec.Command("conntrack", args...).Run()
|
||||
}
|
||||
|
||||
func portMappingConntrackDeleteArgs(mapping config.PortMapping) []string {
|
||||
protocol := strings.ToLower(strings.TrimSpace(mapping.Protocol))
|
||||
if protocol != "tcp" && protocol != "udp" {
|
||||
return nil
|
||||
}
|
||||
if mapping.HostPort < 1 || mapping.HostPort > 65535 {
|
||||
return nil
|
||||
}
|
||||
args := []string{
|
||||
"-D",
|
||||
"-p", protocol,
|
||||
"--dport", strconv.Itoa(mapping.HostPort),
|
||||
}
|
||||
if hostIP := strings.TrimSpace(mapping.HostIP); hostIP != "" {
|
||||
args = append(args, "--dst", hostIP)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// SetupDefaultPortMappings creates default port mappings
|
||||
@@ -361,14 +497,19 @@ func (m *Manager) UpdatePortMapping(id int, index int, pm config.PortMapping) ([
|
||||
if index < 0 || index >= len(c.PortMappings) {
|
||||
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
||||
}
|
||||
existing := c.PortMappings[index]
|
||||
normalized, err := normalizePortMapping(c, index, pm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.EqualFold(existing.Description, "SSH") {
|
||||
normalized.Description = "SSH"
|
||||
}
|
||||
c.PortMappings[index] = normalized
|
||||
if err := persistAndReloadMappings(m, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clearPortMappingConntrack(existing)
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
@@ -381,17 +522,20 @@ func (m *Manager) DeletePortMapping(id int, index int) ([]config.PortMapping, er
|
||||
if index < 0 || index >= len(c.PortMappings) {
|
||||
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
||||
}
|
||||
if c.PortMappings[index].Description == "SSH" {
|
||||
removed := c.PortMappings[index]
|
||||
if strings.EqualFold(removed.Description, "SSH") {
|
||||
return nil, fmt.Errorf("SSH default mapping cannot be deleted")
|
||||
}
|
||||
c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...)
|
||||
if err := persistAndReloadMappings(m, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clearPortMappingConntrack(removed)
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
||||
syncContainerSSHPort(c)
|
||||
config.SaveConfig()
|
||||
if c.Status == "running" && c.IP != "" {
|
||||
return m.ApplyPortMappings(c.ID)
|
||||
@@ -399,6 +543,76 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncContainerSSHPort(c *config.Container) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
for _, mapping := range c.PortMappings {
|
||||
if strings.EqualFold(mapping.Description, "SSH") {
|
||||
c.SSHPort = mapping.HostPort
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if c.UsesLANIPv4() {
|
||||
return nil, fmt.Errorf("public IPv4 cannot be assigned while LAN IPv4 mode is enabled")
|
||||
}
|
||||
|
||||
assignments := []config.PublicIPv4Assignment{}
|
||||
if auto || len(requested) > 0 {
|
||||
allocated, err := AllocatePublicIPv4Assignments(id, requested, count, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignments = allocated
|
||||
}
|
||||
|
||||
c.PublicIPv4s = assignments
|
||||
reconcilePortMappingHostIPs(c)
|
||||
c.NormalizeNetworkAssignments()
|
||||
config.SaveConfig()
|
||||
|
||||
_ = m.CleanPortMappings(id)
|
||||
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||
if c.Status == "running" && c.IP != "" {
|
||||
if err := m.ApplyPortMappings(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func reconcilePortMappingHostIPs(c *config.Container) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
assigned := map[string]bool{}
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if addr := strings.TrimSpace(item.Address); addr != "" {
|
||||
assigned[addr] = true
|
||||
}
|
||||
}
|
||||
replacement := ""
|
||||
if len(assigned) == 1 {
|
||||
for addr := range assigned {
|
||||
replacement = addr
|
||||
}
|
||||
}
|
||||
for i := range c.PortMappings {
|
||||
hostIP := strings.TrimSpace(c.PortMappings[i].HostIP)
|
||||
if hostIP == "" || assigned[hostIP] {
|
||||
continue
|
||||
}
|
||||
c.PortMappings[i].HostIP = replacement
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
|
||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||
return pm, fmt.Errorf("container port must be 1-65535")
|
||||
@@ -451,6 +665,283 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
// SetupCreatePortMappings appends validated custom or automatically allocated
|
||||
// mappings to a container's management port mapping.
|
||||
func SetupCreatePortMappings(c *config.Container, cfg ContainerConfig) ([]config.PortMapping, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container is required")
|
||||
}
|
||||
requested := append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||
if len(requested) == 0 && cfg.PortMappingCount > 1 {
|
||||
for _, port := range allocateDefaultEqualPorts(c, cfg.PortMappingCount-1) {
|
||||
requested = append(requested, config.PortMapping{
|
||||
ContainerPort: port,
|
||||
HostPort: port,
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", port),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, mapping := range requested {
|
||||
pm, err := normalizePortMapping(c, -1, mapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.PortMappings = append(c.PortMappings, pm)
|
||||
}
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
// ReserveCreateNATPorts keeps concurrent create tasks from selecting each
|
||||
// other's custom or management ports before their containers are persisted.
|
||||
func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) {
|
||||
if !cfg.WantsNAT() {
|
||||
return 0, func() {}, nil
|
||||
}
|
||||
|
||||
createNATReservationMu.Lock()
|
||||
defer createNATReservationMu.Unlock()
|
||||
|
||||
owner := createNATReservationOwner(cfg.Name)
|
||||
requestedReservations := createNATReservationMappings(cfg, cfg.ManagementPort)
|
||||
if queued, ok := queuedCreateNATReservations[owner]; ok {
|
||||
if !sameCreateNATReservations(queued, requestedReservations) {
|
||||
return 0, nil, fmt.Errorf("queued NAT port plan for %s no longer matches the create task", cfg.Name)
|
||||
}
|
||||
delete(queuedCreateNATReservations, owner)
|
||||
return activateCreateNATReservationLocked(cfg.ManagementPort, queued)
|
||||
}
|
||||
|
||||
if err := ValidateCreateNATPortAvailability(cfg); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if err := validateCreateNATReservationsAvailableLocked(requestedReservations, owner); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
excluded := cfg.RequestedNATHostPorts()
|
||||
excluded = append(excluded, allReservedCreateNATHostPortsLocked(owner)...)
|
||||
managementPort := cfg.ManagementPort
|
||||
if managementPort == 0 {
|
||||
var err error
|
||||
managementPort, err = config.AllocateSSHPortExcluding(excluded)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
reservations := createNATReservationMappings(cfg, managementPort)
|
||||
return activateCreateNATReservationLocked(managementPort, reservations)
|
||||
}
|
||||
|
||||
// ReserveBatchCreateNATPorts resolves every automatic NAT port and reserves
|
||||
// the complete batch before any create task is enqueued.
|
||||
func ReserveBatchCreateNATPorts(configs []ContainerConfig) ([]ContainerConfig, error) {
|
||||
createNATReservationMu.Lock()
|
||||
defer createNATReservationMu.Unlock()
|
||||
|
||||
planned := append([]ContainerConfig(nil), configs...)
|
||||
addedOwners := make([]string, 0, len(planned))
|
||||
rollback := func() {
|
||||
for _, owner := range addedOwners {
|
||||
delete(queuedCreateNATReservations, owner)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range planned {
|
||||
cfg := &planned[i]
|
||||
cfg.NATPortMappings = append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||
if !cfg.WantsNAT() {
|
||||
continue
|
||||
}
|
||||
owner := createNATReservationOwner(cfg.Name)
|
||||
if owner == "" {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("container name is required for NAT port reservation")
|
||||
}
|
||||
if _, exists := queuedCreateNATReservations[owner]; exists {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("container creation already has reserved NAT ports: %s", cfg.Name)
|
||||
}
|
||||
if err := ValidateCreateNATPortAvailability(*cfg); err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
explicit := createNATReservationMappings(*cfg, cfg.ManagementPort)
|
||||
if err := validateCreateNATReservationsAvailableLocked(explicit, owner); err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
excluded := cfg.RequestedNATHostPorts()
|
||||
excluded = append(excluded, allReservedCreateNATHostPortsLocked(owner)...)
|
||||
if cfg.ManagementPort == 0 {
|
||||
port, err := config.AllocateSSHPortExcluding(excluded)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
cfg.ManagementPort = port
|
||||
}
|
||||
|
||||
if len(cfg.NATPortMappings) == 0 && cfg.PortMappingCount > 1 {
|
||||
generated, err := planDefaultCreateNATMappingsLocked(*cfg, cfg.PortMappingCount-1, owner)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
cfg.NATPortMappings = generated
|
||||
cfg.PortMappingCount = len(generated) + 1
|
||||
}
|
||||
|
||||
reservations := createNATReservationMappings(*cfg, cfg.ManagementPort)
|
||||
if err := validateCreateNATReservationsAvailableLocked(reservations, owner); err != nil {
|
||||
rollback()
|
||||
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||
}
|
||||
queuedCreateNATReservations[owner] = reservations
|
||||
addedOwners = append(addedOwners, owner)
|
||||
}
|
||||
return planned, nil
|
||||
}
|
||||
|
||||
func ReleaseQueuedCreateNATPorts(name string) {
|
||||
owner := createNATReservationOwner(name)
|
||||
if owner == "" {
|
||||
return
|
||||
}
|
||||
createNATReservationMu.Lock()
|
||||
delete(queuedCreateNATReservations, owner)
|
||||
createNATReservationMu.Unlock()
|
||||
}
|
||||
|
||||
func activateCreateNATReservationLocked(managementPort int, reservations []config.PortMapping) (int, func(), error) {
|
||||
createNATReservationNextID++
|
||||
reservationID := createNATReservationNextID
|
||||
createNATReservations[reservationID] = append([]config.PortMapping(nil), reservations...)
|
||||
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
once.Do(func() {
|
||||
createNATReservationMu.Lock()
|
||||
delete(createNATReservations, reservationID)
|
||||
createNATReservationMu.Unlock()
|
||||
})
|
||||
}
|
||||
return managementPort, release, nil
|
||||
}
|
||||
|
||||
func createNATReservationOwner(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func createNATReservationMappings(cfg ContainerConfig, managementPort int) []config.PortMapping {
|
||||
reservations := make([]config.PortMapping, 0, len(cfg.NATPortMappings))
|
||||
if managementPort > 0 {
|
||||
reservations = append(reservations, config.PortMapping{HostPort: managementPort, Protocol: "tcp"})
|
||||
}
|
||||
reservations = append(reservations, cfg.NATPortMappings...)
|
||||
return reservations
|
||||
}
|
||||
|
||||
func validateCreateNATReservationsAvailableLocked(requested []config.PortMapping, exceptOwner string) error {
|
||||
for _, candidate := range requested {
|
||||
for _, reservations := range createNATReservations {
|
||||
if conflictingCreateNATReservation(candidate, reservations) {
|
||||
return fmt.Errorf("NAT host port %d/%s is reserved by another create task", candidate.HostPort, candidate.Protocol)
|
||||
}
|
||||
}
|
||||
for owner, reservations := range queuedCreateNATReservations {
|
||||
if owner == exceptOwner {
|
||||
continue
|
||||
}
|
||||
if conflictingCreateNATReservation(candidate, reservations) {
|
||||
return fmt.Errorf("NAT host port %d/%s is reserved by queued create task %s", candidate.HostPort, candidate.Protocol, owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func conflictingCreateNATReservation(candidate config.PortMapping, reservations []config.PortMapping) bool {
|
||||
for _, reserved := range reservations {
|
||||
if candidate.HostPort == reserved.HostPort && protocolsOverlap(candidate.Protocol, reserved.Protocol) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allReservedCreateNATHostPortsLocked(exceptOwner string) []int {
|
||||
ports := make([]int, 0)
|
||||
for _, reservations := range createNATReservations {
|
||||
for _, reserved := range reservations {
|
||||
ports = append(ports, reserved.HostPort)
|
||||
}
|
||||
}
|
||||
for owner, reservations := range queuedCreateNATReservations {
|
||||
if owner == exceptOwner {
|
||||
continue
|
||||
}
|
||||
for _, reserved := range reservations {
|
||||
ports = append(ports, reserved.HostPort)
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func planDefaultCreateNATMappingsLocked(cfg ContainerConfig, count int, owner string) ([]config.PortMapping, error) {
|
||||
if count <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
unavailable := map[int]bool{cfg.ManagementPort: true}
|
||||
for _, port := range allReservedCreateNATHostPortsLocked(owner) {
|
||||
unavailable[port] = true
|
||||
}
|
||||
for _, mapping := range cfg.NATPortMappings {
|
||||
unavailable[mapping.HostPort] = true
|
||||
}
|
||||
|
||||
candidate := &config.Container{ID: -1}
|
||||
start, end := config.NATPortRange()
|
||||
mappings := make([]config.PortMapping, 0, count)
|
||||
for port := start; port <= end && len(mappings) < count; port++ {
|
||||
if unavailable[port] || !HostPortAvailable(candidate, "", port, "tcp") {
|
||||
continue
|
||||
}
|
||||
unavailable[port] = true
|
||||
mappings = append(mappings, config.PortMapping{
|
||||
HostPort: port,
|
||||
ContainerPort: port,
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", port),
|
||||
})
|
||||
}
|
||||
if len(mappings) != count {
|
||||
return nil, fmt.Errorf("not enough free NAT4 host ports for %d automatic mappings", count)
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
func sameCreateNATReservations(left, right []config.PortMapping) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
counts := make(map[string]int, len(left))
|
||||
for _, mapping := range left {
|
||||
counts[fmt.Sprintf("%d/%s", mapping.HostPort, strings.ToLower(mapping.Protocol))]++
|
||||
}
|
||||
for _, mapping := range right {
|
||||
key := fmt.Sprintf("%d/%s", mapping.HostPort, strings.ToLower(mapping.Protocol))
|
||||
if counts[key] == 0 {
|
||||
return false
|
||||
}
|
||||
counts[key]--
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
var snapshotMu sync.Mutex
|
||||
|
||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
|
||||
@@ -42,12 +42,21 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
|
||||
if _, err := os.Stat(containerDir); err != nil {
|
||||
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
|
||||
}
|
||||
pool, err := config.SelectStoragePoolForContent(
|
||||
config.StorageContentSnapshots,
|
||||
firstString(storagePoolID),
|
||||
dirSizeBytes(containerDir),
|
||||
)
|
||||
if err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||
// Use container ID instead of lxcName to avoid collision when containers are recreated
|
||||
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
|
||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
||||
baseDir := filepath.Join(pool.Path, "snapshots")
|
||||
snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID)
|
||||
if err := safePathUnder(snapshotDir, baseDir); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
||||
@@ -100,7 +109,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
|
||||
|
||||
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||
if snapshot.Path != "" {
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||
@@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
||||
if snapshot.Path == "" {
|
||||
return fmt.Errorf("snapshot path is empty")
|
||||
}
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||
@@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD
|
||||
}
|
||||
|
||||
func snapshotBaseDir() string {
|
||||
return filepath.Join(config.AppConfig.DataDir, "snapshots")
|
||||
return snapshotBaseDirForPool("")
|
||||
}
|
||||
|
||||
func snapshotBaseDirForPool(poolID string) string {
|
||||
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
|
||||
return filepath.Join(pool.Path, "snapshots")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func safeSnapshotPath(path string) error {
|
||||
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
|
||||
return nil
|
||||
}
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
|
||||
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unsafe snapshot path: %s", path)
|
||||
}
|
||||
|
||||
func firstString(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(values[0])
|
||||
}
|
||||
|
||||
func copyTree(src string, dst string) error {
|
||||
@@ -313,6 +348,9 @@ func copyTree(src string, dst string) error {
|
||||
}
|
||||
|
||||
func dirSizeBytes(path string) int64 {
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||
path = resolved
|
||||
}
|
||||
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package lxc
|
||||
|
||||
import "runtime"
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// Template represents an LXC image template
|
||||
type Template struct {
|
||||
@@ -11,12 +15,15 @@ type Template struct {
|
||||
Arch string `json:"arch"`
|
||||
Variant string `json:"variant"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Custom bool `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
// GetTemplates returns available LXC image templates (only verified working ones)
|
||||
func GetTemplates() []Template {
|
||||
arch := defaultTemplateArch()
|
||||
return []Template{
|
||||
templates := []Template{
|
||||
{
|
||||
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
|
||||
Distro: "ubuntu", Release: "noble", Arch: arch,
|
||||
@@ -68,6 +75,23 @@ func GetTemplates() []Template {
|
||||
Description: "Rocky Linux 10",
|
||||
},
|
||||
}
|
||||
for _, custom := range config.ListCustomLXCImages() {
|
||||
if custom.Arch != arch {
|
||||
continue
|
||||
}
|
||||
templates = append(templates, Template{
|
||||
ID: custom.ID,
|
||||
Name: custom.Name,
|
||||
Distro: custom.Distro,
|
||||
Release: custom.Release,
|
||||
Arch: custom.Arch,
|
||||
Description: custom.Description,
|
||||
URL: custom.URL,
|
||||
SHA256: custom.SHA256,
|
||||
Custom: true,
|
||||
})
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
func defaultTemplateArch() string {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package safehttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxRedirects = 10
|
||||
|
||||
var blockedPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("::/128"),
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
netip.MustParsePrefix("2001::/32"),
|
||||
netip.MustParsePrefix("2001:2::/48"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("2001:20::/28"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
netip.MustParsePrefix("fec0::/10"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
}
|
||||
|
||||
// ValidateURL performs the URL checks that do not require DNS. Host addresses
|
||||
// are checked again after resolution and immediately before every connection.
|
||||
func ValidateURL(rawURL string) (*url.URL, error) {
|
||||
if len(rawURL) == 0 || len(rawURL) > 4096 {
|
||||
return nil, fmt.Errorf("download URL must be between 1 and 4096 characters")
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid download URL: %v", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("download URL must use HTTP or HTTPS")
|
||||
}
|
||||
if parsed.Host == "" || parsed.Hostname() == "" {
|
||||
return nil, fmt.Errorf("download URL must include a host")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, fmt.Errorf("download URL must not include credentials")
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("download URL must not include a fragment")
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return nil, fmt.Errorf("download URL contains an invalid port")
|
||||
}
|
||||
}
|
||||
if addr, err := netip.ParseAddr(parsed.Hostname()); err == nil && !isPublicAddress(addr) {
|
||||
return nil, fmt.Errorf("download URL resolves to a non-public address")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// Get retrieves a resource only when every resolved destination is public.
|
||||
func Get(ctx context.Context, rawURL, userAgent string, timeout time.Duration) (*http.Response, error) {
|
||||
parsed, err := ValidateURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateHost(ctx, net.DefaultResolver, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("User-Agent", userAgent)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: publicTransport(net.DefaultResolver),
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
redirect, err := ValidateURL(req.URL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateHost(req.Context(), net.DefaultResolver, redirect.Hostname()); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(via) > 0 {
|
||||
req.Header.Set("User-Agent", via[0].Header.Get("User-Agent"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// All URL components, redirects, DNS answers and dial destinations are
|
||||
// constrained above and in publicTransport.
|
||||
// lgtm[go/request-forgery]
|
||||
return client.Do(request)
|
||||
}
|
||||
|
||||
func publicTransport(resolver *net.Resolver) *http.Transport {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
return &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid download destination: %v", err)
|
||||
}
|
||||
addresses, err := resolvePublicHost(ctx, resolver, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
for _, addr := range addresses {
|
||||
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("host has no usable public addresses")
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func validateHost(ctx context.Context, resolver *net.Resolver, host string) error {
|
||||
_, err := resolvePublicHost(ctx, resolver, host)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolvePublicHost(ctx context.Context, resolver *net.Resolver, host string) ([]netip.Addr, error) {
|
||||
host = strings.TrimSpace(strings.TrimSuffix(host, "."))
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("download URL host is empty")
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
|
||||
return nil, fmt.Errorf("download URL host is not public")
|
||||
}
|
||||
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
addr = addr.Unmap()
|
||||
if !isPublicAddress(addr) {
|
||||
return nil, fmt.Errorf("download URL resolves to a non-public address")
|
||||
}
|
||||
return []netip.Addr{addr}, nil
|
||||
}
|
||||
|
||||
addresses, err := resolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve download host: %v", err)
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return nil, fmt.Errorf("download host has no IP addresses")
|
||||
}
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if !isPublicAddress(address) {
|
||||
return nil, fmt.Errorf("download host resolves to a non-public address")
|
||||
}
|
||||
result = append(result, address)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isPublicAddress(address netip.Addr) bool {
|
||||
if !address.IsValid() || address.Zone() != "" || !address.IsGlobalUnicast() || address.IsPrivate() ||
|
||||
address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() ||
|
||||
address.IsMulticast() || address.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
for _, prefix := range blockedPrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package safehttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateURLRejectsUnsafeDestinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, rawURL := range []string{
|
||||
"file:///etc/passwd",
|
||||
"http://user:pass@example.com/image",
|
||||
"http://127.0.0.1/image",
|
||||
"http://[::1]/image",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"http://10.0.0.1/image",
|
||||
"http://192.168.1.10/image",
|
||||
"http://100.64.0.1/image",
|
||||
"http://example.com:99999/image",
|
||||
} {
|
||||
if _, err := ValidateURL(rawURL); err == nil {
|
||||
t.Fatalf("ValidateURL(%q) succeeded, want rejection", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateURLAcceptsPublicHTTPURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed, err := ValidateURL("https://example.com/images/rootfs.tar.xz?variant=default")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateURL returned error: %v", err)
|
||||
}
|
||||
if parsed.Hostname() != "example.com" {
|
||||
t.Fatalf("hostname = %q, want example.com", parsed.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublicAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]bool{
|
||||
"8.8.8.8": true,
|
||||
"1.1.1.1": true,
|
||||
"2606:4700:4700::1111": true,
|
||||
"127.0.0.1": false,
|
||||
"10.0.0.1": false,
|
||||
"100.64.0.1": false,
|
||||
"169.254.169.254": false,
|
||||
"192.0.2.1": false,
|
||||
"198.18.0.1": false,
|
||||
"::1": false,
|
||||
"64:ff9b::127.0.0.1": false,
|
||||
"2002:7f00:1::1": false,
|
||||
"fc00::1": false,
|
||||
"fec0::1": false,
|
||||
"fe80::1": false,
|
||||
"2001:db8::1": false,
|
||||
}
|
||||
for raw, expected := range tests {
|
||||
if actual := isPublicAddress(netip.MustParseAddr(raw)); actual != expected {
|
||||
t.Errorf("isPublicAddress(%s) = %v, want %v", raw, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRejectsLoopbackBeforeRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if _, err := Get(ctx, "http://127.0.0.1:1/image", "test", time.Second); err == nil {
|
||||
t.Fatal("Get accepted a loopback destination")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func panelAccessMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
decision := config.EvaluatePanelAccess(
|
||||
config.AppConfig.PanelAccessPolicy,
|
||||
r.RemoteAddr,
|
||||
config.ForwardedClientHeaders{
|
||||
ForwardedFor: r.Header.Get("X-Forwarded-For"),
|
||||
RealIP: r.Header.Get("X-Real-IP"),
|
||||
CFConnectingIP: r.Header.Get("CF-Connecting-IP"),
|
||||
},
|
||||
)
|
||||
if decision.Allowed {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": false,
|
||||
"message": "Access denied by panel source policy",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "Access denied by panel source policy", http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestPanelAccessMiddleware(t *testing.T) {
|
||||
previous := config.AppConfig
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
PanelAccessPolicy: config.PanelAccessPolicy{
|
||||
Enabled: true,
|
||||
AllowedSources: []string{"192.0.2.0/24"},
|
||||
TrustedProxies: []string{"10.0.0.1"},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
config.AppConfig = previous
|
||||
})
|
||||
|
||||
handler := panelAccessMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
allowed := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
allowed.RemoteAddr = "192.0.2.8:50000"
|
||||
allowedRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(allowedRecorder, allowed)
|
||||
if allowedRecorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("allowed status = %d", allowedRecorder.Code)
|
||||
}
|
||||
|
||||
denied := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
denied.RemoteAddr = "198.51.100.8:50000"
|
||||
deniedRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(deniedRecorder, denied)
|
||||
if deniedRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("denied status = %d", deniedRecorder.Code)
|
||||
}
|
||||
if got := deniedRecorder.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf("denied content type = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,13 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||
mux.HandleFunc("/api/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy)))
|
||||
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
|
||||
mux.HandleFunc("/api/images/custom", corsMiddleware(api.AdminMiddleware(api.HandleCustomKVMImages)))
|
||||
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
|
||||
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
|
||||
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
|
||||
@@ -66,9 +68,11 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
|
||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
|
||||
mux.HandleFunc("/api/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
|
||||
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
|
||||
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
|
||||
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
|
||||
@@ -99,6 +103,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
|
||||
mux.HandleFunc("/api/v1/images/custom", corsMiddleware(api.AuthMiddleware(api.HandleCustomKVMImages)))
|
||||
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
|
||||
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
|
||||
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
|
||||
@@ -110,9 +115,11 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
||||
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/v1/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
|
||||
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
||||
mux.HandleFunc("/api/v1/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
|
||||
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
||||
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
||||
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
||||
@@ -122,6 +129,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||
mux.HandleFunc("/api/v1/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy)))
|
||||
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
||||
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
||||
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||
@@ -188,7 +196,7 @@ func Run() error {
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
Handler: panelAccessMiddleware(mux),
|
||||
}
|
||||
|
||||
if sslEnabled() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.24"
|
||||
Version = "1.1.28"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"clicd/internal/api"
|
||||
"clicd/internal/cli"
|
||||
@@ -16,12 +19,15 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var shutdownCaptureOnce sync.Once
|
||||
|
||||
func main() {
|
||||
isTerminal := term.IsTerminal(int(os.Stdin.Fd()))
|
||||
|
||||
isServerMode := false
|
||||
isCliMode := false
|
||||
noWebAutostart := false
|
||||
isAccessPolicyCommand := len(os.Args) > 1 && os.Args[1] == "access-policy"
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "server" || arg == "-s" || arg == "--server" {
|
||||
isServerMode = true
|
||||
@@ -43,8 +49,19 @@ func main() {
|
||||
}
|
||||
_ = cfg
|
||||
|
||||
if isAccessPolicyCommand {
|
||||
if err := cli.RunAccessPolicyCommand(os.Args[2:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Access policy error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isServerMode || (!isTerminal && !isCliMode) {
|
||||
installShutdownStateCapture()
|
||||
|
||||
// Restore persisted state
|
||||
api.ConfigureTaskQueue(cfg.TaskConcurrency)
|
||||
api.RestoreTasks()
|
||||
api.RestoreLoginLogs()
|
||||
|
||||
@@ -75,6 +92,7 @@ func main() {
|
||||
|
||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||
config.CleanStaleContainers()
|
||||
api.StartHostBootRestore()
|
||||
lxc.EnsureAllRunningPortMappings()
|
||||
|
||||
// Pre-warm SSH for containers already running after host boot or service restart.
|
||||
@@ -97,6 +115,17 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func installShutdownStateCapture() {
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-signals
|
||||
fmt.Fprintf(os.Stderr, "Received %s, capturing workload restore state...\n", sig)
|
||||
shutdownCaptureOnce.Do(api.CaptureRuntimeRestoreState)
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
func isWebPanelSystemdRunning() bool {
|
||||
cmd := exec.Command("systemctl", "is-active", "clicd")
|
||||
output, err := cmd.Output()
|
||||
|
||||
@@ -191,6 +191,26 @@ Update example:
|
||||
| `disabled` | Whether this key is disabled. |
|
||||
| `container_uuids` | Optional container allowlist that limits the key to specific containers. |
|
||||
|
||||
## Panel Access Source Policy
|
||||
|
||||
Use `GET /api/v1/access-policy` to read the panel source allowlist and `PUT /api/v1/access-policy` to update it. Both endpoints require `admin:access`. The policy covers panel pages, login endpoints, and every API.
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"allowed_sources": [
|
||||
"203.0.113.10",
|
||||
"192.168.1.0/24",
|
||||
"2001:db8::/32"
|
||||
],
|
||||
"trusted_proxies": [
|
||||
"127.0.0.1"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Both lists accept IPv4, IPv6, and CIDR values. The backend only uses `X-Forwarded-For`, `X-Real-IP`, or `CF-Connecting-IP` when the direct peer matches `trusted_proxies`, so untrusted clients cannot bypass the policy by spoofing those headers. An enabled policy requires at least one allowed source, and the API rejects changes that exclude the current administrator source. Direct loopback access remains available as a CLI/SSH recovery path.
|
||||
|
||||
## Python Example
|
||||
|
||||
Fetch containers:
|
||||
@@ -302,6 +322,8 @@ print(resp.json())
|
||||
| GET | `/api/v1/templates` | Template list |
|
||||
| GET | `/api/v1/images` | Image management list |
|
||||
| GET | `/api/v1/images/enabled` | Enabled and downloaded images; supports `type=lxc\|kvm` |
|
||||
| POST | `/api/v1/images/custom` | Add a third-party LXC/KVM image source |
|
||||
| DELETE | `/api/v1/images/custom` | Remove a third-party LXC/KVM image source and cache |
|
||||
| POST | `/api/v1/images/download` | Download image |
|
||||
| POST | `/api/v1/images/cancel` | Cancel image download |
|
||||
| DELETE | `/api/v1/images/delete` | Delete image cache |
|
||||
|
||||
@@ -21,6 +21,23 @@ systemctl restart clicd
|
||||
journalctl -u clicd -n 100 --no-pager
|
||||
```
|
||||
|
||||
## Panel Access Allowlist CLI
|
||||
|
||||
```bash
|
||||
# Show the current policy
|
||||
clicd access-policy show
|
||||
|
||||
# Allow selected addresses and networks; add reverse proxies when needed
|
||||
clicd access-policy set \
|
||||
--allow "203.0.113.10,192.168.1.0/24,2001:db8::/32" \
|
||||
--trusted-proxy "127.0.0.1"
|
||||
|
||||
# Disable source restrictions
|
||||
clicd access-policy disable
|
||||
```
|
||||
|
||||
The same controls are available from the "Panel access allowlist" item in `clicd cli`. Both paths persist the setting and restart the running panel service automatically.
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
- Do not expose the web panel directly to untrusted networks.
|
||||
|
||||
@@ -17,6 +17,8 @@ CLICD provides a one-line installer. By default, it installs the latest version
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
The installer asks for separate LXC and KVM NAT private subnets. Press Enter to scan host routes, interfaces, bridges, and libvirt networks and select non-overlapping RFC1918 `/24` networks, or enter a CIDR such as `172.28.40.0/24`. For unattended installation, set `CLICD_LXC_SUBNET` and `CLICD_KVM_SUBNET`.
|
||||
|
||||
The script defaults to `CLICD_VERSION=latest` and downloads `clicd-linux-amd64.tar.gz` or `clicd-linux-arm64.tar.gz` from `releases/latest` according to the host architecture.
|
||||
|
||||
## Install a Specific Version
|
||||
|
||||
@@ -191,6 +191,26 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
|
||||
| `disabled` | 是否禁用该 Key。 |
|
||||
| `container_uuids` | 可选;限制该 Key 只能访问指定容器。 |
|
||||
|
||||
## 面板访问来源策略
|
||||
|
||||
`GET /api/v1/access-policy` 读取面板访问白名单,`PUT /api/v1/access-policy` 更新策略。两者均需要 `admin:access` 权限。策略覆盖面板页面、登录入口和全部 API。
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"allowed_sources": [
|
||||
"203.0.113.10",
|
||||
"192.168.1.0/24",
|
||||
"2001:db8::/32"
|
||||
],
|
||||
"trusted_proxies": [
|
||||
"127.0.0.1"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`allowed_sources` 和 `trusted_proxies` 均支持 IPv4、IPv6 及 CIDR。只有直接连接来源命中 `trusted_proxies` 时,后端才会使用 `X-Forwarded-For`、`X-Real-IP` 或 `CF-Connecting-IP`;其他客户端伪造这些请求头不会绕过白名单。启用策略时至少要配置一个允许来源,且接口会拒绝排除当前管理来源的配置。本机回环直连保留为 CLI/SSH 故障恢复通道。
|
||||
|
||||
## Python 示例
|
||||
|
||||
获取容器列表:
|
||||
@@ -302,6 +322,8 @@ print(resp.json())
|
||||
| GET | `/api/v1/templates` | 模板列表 |
|
||||
| GET | `/api/v1/images` | 镜像管理列表 |
|
||||
| GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` |
|
||||
| POST | `/api/v1/images/custom` | 添加第三方 LXC/KVM 镜像源 |
|
||||
| DELETE | `/api/v1/images/custom` | 移除第三方 LXC/KVM 镜像源及缓存 |
|
||||
| POST | `/api/v1/images/download` | 下载镜像 |
|
||||
| POST | `/api/v1/images/cancel` | 取消镜像下载 |
|
||||
| DELETE | `/api/v1/images/delete` | 删除镜像缓存 |
|
||||
|
||||
@@ -21,6 +21,23 @@ systemctl restart clicd
|
||||
journalctl -u clicd -n 100 --no-pager
|
||||
```
|
||||
|
||||
## 面板访问白名单 CLI
|
||||
|
||||
```bash
|
||||
# 查看当前策略
|
||||
clicd access-policy show
|
||||
|
||||
# 仅允许指定 IP/网段;反向代理地址按需填写
|
||||
clicd access-policy set \
|
||||
--allow "203.0.113.10,192.168.1.0/24,2001:db8::/32" \
|
||||
--trusted-proxy "127.0.0.1"
|
||||
|
||||
# 关闭白名单限制
|
||||
clicd access-policy disable
|
||||
```
|
||||
|
||||
也可以运行 `clicd cli`,在交互菜单中选择“面板访问白名单”。直接命令和交互菜单都会保存配置,并在服务运行时自动重启面板。
|
||||
|
||||
## 安全建议
|
||||
|
||||
- 不要把 Web 面板直接暴露给不可信来源。
|
||||
|
||||
@@ -17,6 +17,8 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
安装器会分别询问 LXC 与 KVM 的 NAT 私网网段。直接回车时,脚本会扫描宿主机路由、网卡、网桥和 libvirt 网络,自动选择未冲突的 RFC1918 `/24` 网段;也可以输入 `172.28.40.0/24` 这类 CIDR。非交互安装可设置 `CLICD_LXC_SUBNET` 和 `CLICD_KVM_SUBNET`。
|
||||
|
||||
脚本当前默认使用 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz` 或 `clicd-linux-arm64.tar.gz`。
|
||||
|
||||
## 安装指定版本
|
||||
|
||||
Generated
+7
-7
@@ -2065,9 +2065,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2123,9 +2123,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2143,7 +2143,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "6.4.3",
|
||||
"esbuild": "0.28.1"
|
||||
"esbuild": "0.28.1",
|
||||
"postcss": "8.5.23"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+56
-96
@@ -1,28 +1,28 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.28",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.18.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-router": "8.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss": "^8.5.23",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^8.0.16"
|
||||
@@ -480,15 +480,6 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
@@ -809,32 +800,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz",
|
||||
"integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==",
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
@@ -957,9 +940,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
|
||||
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
@@ -1152,6 +1135,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-es": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
|
||||
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1625,6 +1614,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
@@ -1934,18 +1924,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -2038,9 +2016,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2144,9 +2122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2164,7 +2142,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2337,28 +2315,24 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
@@ -2372,35 +2346,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
|
||||
"integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3"
|
||||
"cookie-es": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=22.22.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3",
|
||||
"react-router": "6.30.4"
|
||||
"react": ">=19.2.7",
|
||||
"react-dom": ">=19.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
@@ -2525,13 +2488,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.24",
|
||||
"version": "1.1.28",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,18 +12,18 @@
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.18.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-router": "8.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss": "^8.5.23",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^8.0.16"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Routes, Route, Navigate } from 'react-router'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
@@ -13,6 +13,7 @@ import Settings from './pages/Settings'
|
||||
import ImageManagement from './pages/ImageManagement'
|
||||
import Snapshots from './pages/Snapshots'
|
||||
import Routing from './pages/Routing'
|
||||
import Storage from './pages/Storage'
|
||||
import SubUserManagement from './pages/SubUserManagement'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
@@ -63,6 +64,7 @@ function App() {
|
||||
<Route path="security" element={<Security />} />
|
||||
<Route path="snapshots" element={<Snapshots />} />
|
||||
<Route path="routing" element={<Routing />} />
|
||||
<Route path="storage" element={<Storage />} />
|
||||
<Route path="audit-logs" element={<AuditLogs />} />
|
||||
<Route path="api-integration" element={<ApiIntegration />} />
|
||||
<Route path="host-report" element={<HostReport />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation } from 'react-router'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { shouldTranslateText, translateText } from '../utils/i18n'
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useDialog } from './Dialog'
|
||||
|
||||
export default function BrowserDialogTranslator() {
|
||||
const { t } = useLanguage()
|
||||
const { alert: showAlert } = useDialog()
|
||||
|
||||
useEffect(() => {
|
||||
const originalAlert = window.alert
|
||||
const originalConfirm = window.confirm
|
||||
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
|
||||
window.alert = (message?: unknown) => { void showAlert('提示', String(message ?? '')) }
|
||||
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
||||
return () => {
|
||||
window.alert = originalAlert
|
||||
window.confirm = originalConfirm
|
||||
}
|
||||
}, [t])
|
||||
}, [showAlert, t])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
Server,
|
||||
Cpu,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,23 @@
|
||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
|
||||
import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'
|
||||
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type DialogType = 'confirm' | 'alert'
|
||||
|
||||
interface DialogState {
|
||||
open: boolean
|
||||
type: DialogType
|
||||
title: string
|
||||
message: string
|
||||
resolve?: (value: boolean) => void
|
||||
}
|
||||
|
||||
type ToastTone = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface ToastState {
|
||||
id: number
|
||||
title: string
|
||||
message: string
|
||||
tone: ToastTone
|
||||
}
|
||||
|
||||
interface DialogContextType {
|
||||
confirm: (title: string, message: string) => Promise<boolean>
|
||||
alert: (title: string, message: string) => Promise<void>
|
||||
@@ -19,67 +25,107 @@ interface DialogContextType {
|
||||
|
||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
|
||||
const toastStyles = {
|
||||
success: { icon: CheckCircle2, iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-300', borderClass: 'border-emerald-200 dark:border-emerald-800' },
|
||||
error: { icon: CircleAlert, iconClass: 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300', borderClass: 'border-red-200 dark:border-red-800' },
|
||||
warning: { icon: AlertTriangle, iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300', borderClass: 'border-amber-200 dark:border-amber-800' },
|
||||
info: { icon: Info, iconClass: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300', borderClass: 'border-gray-200 dark:border-gray-700' },
|
||||
}
|
||||
|
||||
function toastTone(title: string): ToastTone {
|
||||
if (/失败|错误|异常|不可用|failed|error/i.test(title)) return 'error'
|
||||
if (/提示|警告|未配置|格式|配额|封禁|warning/i.test(title)) return 'warning'
|
||||
if (/完成|成功|已保存|success/i.test(title)) return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, title: '', message: '' })
|
||||
const [toasts, setToasts] = useState<ToastState[]>([])
|
||||
const toastID = useRef(0)
|
||||
const toastTimers = useRef(new Map<number, number>())
|
||||
const { t } = useLanguage()
|
||||
|
||||
const confirm = useCallback((title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setDialog({ open: true, type: 'confirm', title, message, resolve })
|
||||
setDialog({ open: true, title, message, resolve })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id))
|
||||
const timer = toastTimers.current.get(id)
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
toastTimers.current.delete(id)
|
||||
}, [])
|
||||
|
||||
const alert = useCallback((title: string, message: string) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
|
||||
})
|
||||
const id = ++toastID.current
|
||||
setToasts((current) => [...current, { id, title, message, tone: toastTone(title) }].slice(-4))
|
||||
const timer = window.setTimeout(() => dismissToast(id), 4200)
|
||||
toastTimers.current.set(id, timer)
|
||||
return Promise.resolve()
|
||||
}, [dismissToast])
|
||||
|
||||
useEffect(() => () => {
|
||||
toastTimers.current.forEach((timer) => window.clearTimeout(timer))
|
||||
toastTimers.current.clear()
|
||||
}, [])
|
||||
|
||||
const close = (result: boolean) => {
|
||||
dialog.resolve?.(result)
|
||||
setDialog({ open: false, type: 'alert', title: '', message: '' })
|
||||
setDialog({ open: false, title: '', message: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ confirm, alert }}>
|
||||
{children}
|
||||
{dialog.open && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
|
||||
{dialog.type === 'alert' && (
|
||||
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
|
||||
<X className="w-4 h-4" />
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-[120] flex w-[calc(100vw-2rem)] max-w-sm flex-col gap-2" aria-live="polite" aria-atomic="true">
|
||||
{toasts.map((toast) => {
|
||||
const style = toastStyles[toast.tone]
|
||||
const ToastIcon = style.icon
|
||||
return (
|
||||
<div key={toast.id} className={`pointer-events-auto rounded-lg border bg-white shadow-lg dark:bg-gray-900 dark:shadow-black/40 ${style.borderClass}`} role="status">
|
||||
<div className="flex items-start gap-3 p-3.5">
|
||||
<div className={`mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${style.iconClass}`}>
|
||||
<ToastIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{t(toast.title)}</div>
|
||||
<div className="mt-0.5 break-words text-sm leading-5 text-gray-600 dark:text-gray-300">{t(toast.message)}</div>
|
||||
</div>
|
||||
<button onClick={() => dismissToast(toast.id)} className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{dialog.open && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70">
|
||||
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-700">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="flex-1 text-sm font-semibold text-black dark:text-white">{t(dialog.title)}</h3>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{t(dialog.message)}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
|
||||
{dialog.type === 'confirm' && (
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
{t('取消')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
className="rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('取消')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => close(true)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${
|
||||
dialog.type === 'confirm'
|
||||
? 'bg-black text-white hover:bg-gray-800'
|
||||
: 'bg-black text-white hover:bg-gray-800'
|
||||
}`}
|
||||
className="rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
|
||||
>
|
||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
||||
{t('确认')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Outlet } from 'react-router'
|
||||
import Sidebar from './Sidebar'
|
||||
import { useState } from 'react'
|
||||
import AutoTranslate from './AutoTranslate'
|
||||
@@ -12,7 +12,7 @@ export default function Layout() {
|
||||
<AutoTranslate />
|
||||
<BrowserDialogTranslator />
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||
<main className={`min-w-0 flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||
<div className="p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Cpu,
|
||||
Camera,
|
||||
HardDrive,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
@@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||
const isStoragePage = location.pathname.startsWith('/storage')
|
||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||
const isHostReportPage = location.pathname.startsWith('/host-report')
|
||||
@@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
{!collapsed && <span>路由管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/storage')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isStoragePage
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="w-4 h-4" />
|
||||
{!collapsed && <span>存储管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/audit-logs')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import api, { login as apiLogin, checkAuth, LoginResponse } from '../services/api'
|
||||
|
||||
interface AuthContextType {
|
||||
|
||||
@@ -17,6 +17,7 @@ body {
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
@@ -83,6 +84,8 @@ body {
|
||||
/* Shadow */
|
||||
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
||||
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
|
||||
.dark .shadow-lg,
|
||||
.dark .shadow-xl { box-shadow: 0 12px 28px rgba(0,0,0,0.45) !important; }
|
||||
|
||||
/* bg-black buttons in dark mode -> light */
|
||||
.dark .bg-black { background-color: #f9fafb !important; }
|
||||
@@ -121,6 +124,7 @@ body {
|
||||
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
||||
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
||||
.dark .bg-amber-100 { background-color: #78350f !important; }
|
||||
.dark .bg-indigo-50 { background-color: #1e1b4b !important; }
|
||||
|
||||
/* Status badge text */
|
||||
.dark .text-green-700 { color: #6ee7b7 !important; }
|
||||
@@ -129,6 +133,14 @@ body {
|
||||
.dark .text-amber-600 { color: #fcd34d !important; }
|
||||
.dark .text-amber-700 { color: #fcd34d !important; }
|
||||
.dark .text-emerald-700 { color: #6ee7b7 !important; }
|
||||
.dark .text-emerald-600 { color: #6ee7b7 !important; }
|
||||
.dark .text-amber-800 { color: #fde68a !important; }
|
||||
.dark .text-indigo-700 { color: #a5b4fc !important; }
|
||||
|
||||
/* Colored notification borders */
|
||||
.dark .border-emerald-200 { border-color: #065f46 !important; }
|
||||
.dark .border-red-200 { border-color: #991b1b !important; }
|
||||
.dark .border-amber-200 { border-color: #92400e !important; }
|
||||
|
||||
/* Focus ring */
|
||||
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
||||
@@ -137,6 +149,37 @@ body {
|
||||
/* Accent */
|
||||
.dark .accent-black { accent-color: #f9fafb !important; }
|
||||
|
||||
/* Native form controls */
|
||||
.dark input,
|
||||
.dark select,
|
||||
.dark textarea { color-scheme: dark; }
|
||||
|
||||
/* Explicit dark variants take precedence over the compatibility overrides above. */
|
||||
.dark .dark\:bg-white { background-color: #f9fafb !important; }
|
||||
.dark .dark\:bg-gray-950 { background-color: #030712 !important; }
|
||||
.dark .dark\:bg-gray-900 { background-color: #111827 !important; }
|
||||
.dark .dark\:bg-gray-800 { background-color: #1f2937 !important; }
|
||||
.dark .dark\:bg-gray-700 { background-color: #374151 !important; }
|
||||
.dark .dark\:bg-emerald-950 { background-color: #022c22 !important; }
|
||||
.dark .dark\:bg-red-950 { background-color: #450a0a !important; }
|
||||
.dark .dark\:bg-amber-950 { background-color: #451a03 !important; }
|
||||
.dark .dark\:text-white { color: #f9fafb !important; }
|
||||
.dark .dark\:text-black { color: #111827 !important; }
|
||||
.dark .dark\:text-gray-300 { color: #d1d5db !important; }
|
||||
.dark .dark\:text-gray-400 { color: #9ca3af !important; }
|
||||
.dark .dark\:text-gray-500 { color: #6b7280 !important; }
|
||||
.dark .dark\:text-emerald-300 { color: #6ee7b7 !important; }
|
||||
.dark .dark\:text-red-300 { color: #fca5a5 !important; }
|
||||
.dark .dark\:text-amber-300 { color: #fcd34d !important; }
|
||||
.dark .dark\:border-gray-700 { border-color: #374151 !important; }
|
||||
.dark .dark\:border-emerald-800 { border-color: #065f46 !important; }
|
||||
.dark .dark\:border-red-800 { border-color: #991b1b !important; }
|
||||
.dark .dark\:border-amber-800 { border-color: #92400e !important; }
|
||||
.dark .dark\:hover\:bg-gray-800:hover { background-color: #1f2937 !important; color: inherit !important; }
|
||||
.dark .dark\:hover\:bg-gray-700:hover { background-color: #374151 !important; color: inherit !important; }
|
||||
.dark .dark\:hover\:bg-gray-200:hover { background-color: #e5e7eb !important; color: #111827 !important; }
|
||||
.dark .dark\:hover\:text-white:hover { color: #f9fafb !important; }
|
||||
|
||||
/* Spinner */
|
||||
.dark .border-black { border-color: #f9fafb !important; }
|
||||
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
||||
@@ -157,4 +200,6 @@ body {
|
||||
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
|
||||
.dark .peer-checked\:bg-black:checked + *,
|
||||
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
|
||||
.dark .access-policy-switch .access-policy-switch-thumb { background-color: #e5e7eb !important; }
|
||||
.dark .access-policy-switch[aria-checked="true"] .access-policy-switch-thumb { background-color: #111827 !important; }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
|
||||
@@ -81,7 +81,7 @@ const scopeGroups = [
|
||||
['container:delete', '删除容器'],
|
||||
['container:resize', '资源/到期'],
|
||||
['container:traffic', '流量管理'],
|
||||
['container:network', '端口映射'],
|
||||
['container:network', '网络与端口映射'],
|
||||
['container:password', '重置密码'],
|
||||
['ipv6:assign', '分配 IPv6'],
|
||||
],
|
||||
@@ -140,6 +140,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
endpoints: [
|
||||
['GET', '/api/v1/dashboard', '控制面板统计'],
|
||||
['GET', '/api/v1/host-info', '主机资源'],
|
||||
['GET', '/api/v1/host-history', '宿主机历史指标(后台每 30 秒采集)'],
|
||||
['GET', '/api/v1/host-report', '宿主机硬件、网络与运行环境探测报告'],
|
||||
['GET', '/api/v1/routing', 'NAT/IPv4/IPv6 路由'],
|
||||
['PUT', '/api/v1/routing', '更新公网 IPv4/IPv6 池'],
|
||||
['POST', '/api/v1/routing/ipv4-scan', '扫描公网 IPv4 段'],
|
||||
@@ -161,6 +163,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['POST', '/api/v1/containers/{id}/reinstall', '重装'],
|
||||
['DELETE', '/api/v1/containers/{id}/delete', '删除'],
|
||||
['GET', '/api/v1/containers/{id}/usage', '资源用量'],
|
||||
['GET', '/api/v1/containers/{id}/history', '容器历史指标(后台每 30 秒采集)'],
|
||||
['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
|
||||
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
|
||||
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
|
||||
@@ -168,6 +171,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
|
||||
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
|
||||
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
|
||||
['PUT', '/api/v1/containers/{id}/public-ipv4', '更新独立公网 IPv4 地址'],
|
||||
['PUT', '/api/v1/containers/{id}/ipv6-addresses', '更新独立 IPv6 地址'],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -193,6 +198,9 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
endpoints: [
|
||||
['GET', '/api/v1/templates', '模板列表'],
|
||||
['GET', '/api/v1/images', '镜像管理列表'],
|
||||
['GET', '/api/v1/images/enabled?type=lxc&container={id}', '可用于创建或重装的已启用镜像'],
|
||||
['POST', '/api/v1/images/custom', '添加第三方 LXC/KVM 镜像源'],
|
||||
['DELETE', '/api/v1/images/custom', '移除第三方 LXC/KVM 镜像源'],
|
||||
['POST', '/api/v1/images/download', '下载镜像'],
|
||||
['POST', '/api/v1/images/cancel', '取消镜像下载'],
|
||||
['DELETE', '/api/v1/images/delete', '删除镜像缓存'],
|
||||
@@ -211,6 +219,23 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '主机与设置',
|
||||
endpoints: [
|
||||
['GET', '/api/v1/storage', '已挂载磁盘、存储池和空间占用'],
|
||||
['PUT', '/api/v1/storage', '更新各磁盘的存储用途和默认盘'],
|
||||
['GET', '/api/v1/task-queue/settings', '任务队列并发状态'],
|
||||
['PUT', '/api/v1/task-queue/settings', '调整任务并发数量'],
|
||||
['GET', '/api/v1/ssl', 'SSL 配置和证书状态'],
|
||||
['PUT', '/api/v1/ssl', '更新 SSL 配置'],
|
||||
['GET', '/api/v1/webssh-origins', 'WebSSH/VNC Origin 白名单'],
|
||||
['PUT', '/api/v1/webssh-origins', '更新 WebSSH/VNC Origin 白名单'],
|
||||
['GET', '/api/v1/access-policy', '面板访问来源策略'],
|
||||
['PUT', '/api/v1/access-policy', '更新面板访问来源策略'],
|
||||
['GET', '/api/v1/language', '面板语言'],
|
||||
['PUT', '/api/v1/language', '更新面板语言'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '账号与日志',
|
||||
endpoints: [
|
||||
@@ -728,6 +753,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
name: 'demo-lxc-01',
|
||||
virtualization: 'lxc',
|
||||
template_id: 'debian-bookworm',
|
||||
storage_pool_id: 'disk-root',
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
@@ -741,10 +767,26 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
io_speed_mbps: 0,
|
||||
io_read_mbps: 80,
|
||||
io_write_mbps: 30,
|
||||
extra_ports: [8080],
|
||||
extra_ports: [],
|
||||
nat_port_mappings: [
|
||||
{
|
||||
host_port: 30080,
|
||||
container_port: 80,
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
],
|
||||
management_port: 30022,
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
lan_ipv4_mode: '',
|
||||
lan_interface: '',
|
||||
lan_ipv4_address: '',
|
||||
lan_ipv4_prefix_len: 24,
|
||||
lan_ipv4_gateway: '',
|
||||
snapshot_limit: 1,
|
||||
allowed_image_ids: ['debian-bookworm'],
|
||||
image_limit_configured: true,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
public_ipv4s: [],
|
||||
@@ -780,6 +822,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
|
||||
'PUT /api/v1/containers/{id}/public-ipv4': {
|
||||
mode: 'random',
|
||||
count: 1,
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/ipv6-addresses': {
|
||||
mode: 'custom',
|
||||
addresses: ['2001:db8:100::1005'],
|
||||
},
|
||||
'POST /api/v1/containers/{id}/port-mappings': {
|
||||
container_port: 8080,
|
||||
host_port: 61320,
|
||||
@@ -792,16 +842,59 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
'POST /api/v1/containers/{id}/snapshots': { storage_pool_id: 'disk-root' },
|
||||
'POST /api/v1/containers/{id}/snapshots/schedule': {
|
||||
enabled: true,
|
||||
interval_hours: 24,
|
||||
time: '03:00',
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/snapshots/quota': { snapshot_limit: 2 },
|
||||
'POST /api/v1/images/custom': {
|
||||
type: 'kvm',
|
||||
name: 'Custom Ubuntu Cloud',
|
||||
description: 'Private mirror image',
|
||||
distro: 'ubuntu',
|
||||
release: 'noble',
|
||||
arch: 'amd64',
|
||||
url: 'https://images.example.com/ubuntu-noble.qcow2',
|
||||
provisioner: 'linux-cloud-init',
|
||||
sha256: '',
|
||||
},
|
||||
'DELETE /api/v1/images/custom': { id: 'custom-kvm-a1b2c3d4e5' },
|
||||
'POST /api/v1/images/download': { template_id: 'debian-bookworm' },
|
||||
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
|
||||
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
|
||||
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
|
||||
'PUT /api/v1/storage': {
|
||||
pools: [
|
||||
{
|
||||
id: 'disk-root',
|
||||
name: 'system (/)',
|
||||
path: '/var/lib/clicd',
|
||||
mount_point: '/',
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'PUT /api/v1/task-queue/settings': { concurrency: 4 },
|
||||
'PUT /api/v1/ssl': {
|
||||
enabled: true,
|
||||
mode: 'letsencrypt',
|
||||
target: 'panel.example.com',
|
||||
email: 'admin@example.com',
|
||||
apply_now: false,
|
||||
},
|
||||
'PUT /api/v1/webssh-origins': {
|
||||
origins: ['https://panel.example.com'],
|
||||
},
|
||||
'PUT /api/v1/access-policy': {
|
||||
enabled: true,
|
||||
allowed_sources: ['203.0.113.10', '192.168.1.0/24', '2001:db8::/32'],
|
||||
trusted_proxies: ['127.0.0.1'],
|
||||
},
|
||||
'PUT /api/v1/language': { language: 'zh' },
|
||||
'PUT /api/v1/routing': {
|
||||
items: [
|
||||
{
|
||||
@@ -850,7 +943,16 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
assign_nat: true,
|
||||
management_port: 30022,
|
||||
port_mapping_count: 2,
|
||||
nat_port_mappings: [
|
||||
{
|
||||
host_port: 30080,
|
||||
container_port: 80,
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
],
|
||||
snapshot_limit: 1,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
@@ -910,11 +1012,47 @@ const responseSamples: Record<string, unknown> = {
|
||||
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
|
||||
},
|
||||
},
|
||||
'GET /api/v1/host-history': {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
ts: 1784642400000,
|
||||
cpu: 8.4,
|
||||
memory: 21.3,
|
||||
network: 12288,
|
||||
network_rx: 10240,
|
||||
network_tx: 2048,
|
||||
disk_io: 1052672,
|
||||
disk_read: 4096,
|
||||
disk_write: 1048576,
|
||||
disk_usage_pct: 18.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
'GET /api/v1/host-report': {
|
||||
success: true,
|
||||
data: {
|
||||
generated_at: '2026-07-21 14:00:00',
|
||||
hostname: 'ubuntu',
|
||||
os: 'Ubuntu 22.04.5 LTS',
|
||||
kernel: 'Linux 6.8.0-1054-oracle aarch64 GNU/Linux',
|
||||
cpu: { model: 'Neoverse-N1', cores: 4, threads: 4, architecture: 'arm64', virtualization: true },
|
||||
memory: { total_mb: 11980, used_mb: 2100, free_mb: 9880, modules: [] },
|
||||
runtime: { lxc_available: true, kvm_available: false, support_mode: 'lxc_only' },
|
||||
public_ipv4: [{ address: '203.0.113.10', interface: 'eth0' }],
|
||||
ipv6_prefixes: [],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/routing': {
|
||||
success: true,
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
nat4_port_range: { start: 20000, end: 65535 },
|
||||
nat4_next_port: 22005,
|
||||
nat4_networks: {
|
||||
lxc: { subnet: '10.0.3.0/24', gateway: '10.0.3.1', netmask: '255.255.255.0', dhcp_start: '10.0.3.2', dhcp_end: '10.0.3.254', dhcp_max: 253, prefix_bits: 24 },
|
||||
kvm: { subnet: '192.168.122.0/24', gateway: '192.168.122.1', netmask: '255.255.255.0', dhcp_start: '192.168.122.2', dhcp_end: '192.168.122.254', dhcp_max: 253, prefix_bits: 24 },
|
||||
},
|
||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||
ipv6: { used: 31, remaining: 'large', total: 'large' },
|
||||
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
@@ -930,6 +1068,11 @@ const responseSamples: Record<string, unknown> = {
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
nat4_port_range: { start: 20000, end: 65535 },
|
||||
nat4_next_port: 22005,
|
||||
nat4_networks: {
|
||||
lxc: { subnet: '10.0.3.0/24', gateway: '10.0.3.1' },
|
||||
kvm: { subnet: '192.168.122.0/24', gateway: '192.168.122.1' },
|
||||
},
|
||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
|
||||
@@ -1016,6 +1159,12 @@ const responseSamples: Record<string, unknown> = {
|
||||
load15: 0.01,
|
||||
},
|
||||
},
|
||||
'GET /api/v1/containers/{id}/history': {
|
||||
success: true,
|
||||
data: [
|
||||
{ ts: 1784642400000, cpu: 1.2, memory: 5.6, network: 4096, network_rx: 3072, network_tx: 1024, disk_io: 8192, disk_read: 2048, disk_write: 6144 },
|
||||
],
|
||||
},
|
||||
'GET /api/v1/containers/{id}/traffic': {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -1036,6 +1185,16 @@ const responseSamples: Record<string, unknown> = {
|
||||
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
|
||||
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
|
||||
'PUT /api/v1/containers/{id}/public-ipv4': {
|
||||
success: true,
|
||||
message: 'Public IPv4 assignments updated',
|
||||
data: { id: 5, name: 'example-vm', public_ipv4s: ['203.0.113.10'] },
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/ipv6-addresses': {
|
||||
success: true,
|
||||
message: 'IPv6 assignments updated',
|
||||
data: { id: 5, name: 'example-vm', ipv6_addresses: ['2001:db8:100::1005'] },
|
||||
},
|
||||
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
|
||||
'POST /api/v1/containers/{id}/port-mappings': {
|
||||
success: true,
|
||||
@@ -1100,10 +1259,65 @@ const responseSamples: Record<string, unknown> = {
|
||||
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
|
||||
],
|
||||
},
|
||||
'GET /api/v1/images/enabled?type=lxc&container={id}': {
|
||||
success: true,
|
||||
data: [
|
||||
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', type: 'lxc', downloaded: true, enabled: true },
|
||||
],
|
||||
},
|
||||
'POST /api/v1/images/custom': {
|
||||
success: true,
|
||||
message: 'Custom image added',
|
||||
data: { id: 'custom-kvm-a1b2c3d4e5', name: 'Custom Ubuntu Cloud' },
|
||||
},
|
||||
'DELETE /api/v1/images/custom': { success: true, message: 'Custom image removed' },
|
||||
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
|
||||
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
|
||||
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
|
||||
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
|
||||
'GET /api/v1/storage': {
|
||||
success: true,
|
||||
data: {
|
||||
pools: [
|
||||
{
|
||||
id: 'disk-root',
|
||||
name: 'system (/)',
|
||||
path: '/var/lib/clicd',
|
||||
mount_point: '/',
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
enabled: true,
|
||||
available: true,
|
||||
free_bytes: 54653493248,
|
||||
},
|
||||
],
|
||||
disks: [
|
||||
{ name: 'sda2', path: '/dev/sda2', fstype: 'ext4', mount_point: '/', size_bytes: 67331063808, used_bytes: 12677570560, free_bytes: 54653493248 },
|
||||
],
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
},
|
||||
},
|
||||
'PUT /api/v1/storage': {
|
||||
success: true,
|
||||
data: {
|
||||
pools: [{ id: 'disk-root', path: '/var/lib/clicd', mount_point: '/', content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'], enabled: true, available: true }],
|
||||
disks: [],
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/task-queue/settings': { success: true, data: { concurrency: 4, active: 1, pending: 2 } },
|
||||
'PUT /api/v1/task-queue/settings': { success: true, message: '任务队列设置已保存', data: { concurrency: 4, active: 1, pending: 2 } },
|
||||
'GET /api/v1/ssl': {
|
||||
success: true,
|
||||
data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', email: 'admin@example.com', detected_host: 'panel.example.com', certificate: { subject: 'panel.example.com', issuer: "Let's Encrypt", dns_names: ['panel.example.com'], ip_names: [], valid: true } },
|
||||
},
|
||||
'PUT /api/v1/ssl': { success: true, message: 'SSL settings saved', data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', needs_restart: true } },
|
||||
'GET /api/v1/webssh-origins': { success: true, data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
|
||||
'PUT /api/v1/webssh-origins': { success: true, message: 'Origin allowlist saved', data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
|
||||
'GET /api/v1/access-policy': { success: true, data: { enabled: true, allowed_sources: ['203.0.113.10', '192.168.1.0/24'], trusted_proxies: ['127.0.0.1'], current_source: '203.0.113.10', direct_source: '127.0.0.1', using_forwarded: true } },
|
||||
'PUT /api/v1/access-policy': { success: true, message: 'Panel access policy saved', data: { enabled: true, allowed_sources: ['203.0.113.10', '192.168.1.0/24'], trusted_proxies: ['127.0.0.1'], current_source: '203.0.113.10', direct_source: '127.0.0.1', using_forwarded: true } },
|
||||
'GET /api/v1/language': { success: true, data: { language: 'zh' } },
|
||||
'PUT /api/v1/language': { success: true, data: { language: 'zh' } },
|
||||
'GET /api/v1/security/alerts': { success: true, data: [] },
|
||||
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
|
||||
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
|
||||
@@ -1181,13 +1395,18 @@ function endpointNoteFor(key: string) {
|
||||
const notes: string[] = []
|
||||
if (key === 'POST /api/v1/containers') {
|
||||
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
|
||||
notes.push('Set management_port to choose the public/source port for SSH (target 22) or Windows RDP (target 3389). Omit it or pass 0 for automatic allocation.')
|
||||
notes.push('For other custom NAT rules, use nat_port_mappings with host_port (public/source port), container_port (target port), and protocol=tcp|udp. extra_ports remains accepted for compatibility and maps each port to the same port inside the container.')
|
||||
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
|
||||
notes.push('storage_pool_id selects an enabled disk for the runtime. For an LXC with an independent LAN address, set lan_ipv4_mode=dhcp or static and set assign_nat=false; static mode also requires lan_ipv4_address, lan_ipv4_prefix_len, and lan_ipv4_gateway.')
|
||||
notes.push('allowed_image_ids and image_limit_configured define which downloaded images the container owner may use for reinstall. Include the initial template ID when it should remain reinstallable.')
|
||||
}
|
||||
if (key === 'POST /api/v1/containers/{id}/reinstall') {
|
||||
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-create') {
|
||||
notes.push('Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.')
|
||||
notes.push('Each containers[] item in batch creation supports the same storage, network, image allowlist, and SSH authentication fields as POST /api/v1/containers.')
|
||||
notes.push('Custom management_port and NAT host_port values must be unique across the batch. The panel places each later source-port group after the previous container\'s highest public port while keeping every target container_port unchanged; direct API clients should submit the expanded values explicitly.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
|
||||
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
|
||||
@@ -1204,6 +1423,33 @@ function endpointNoteFor(key: string) {
|
||||
if (key === 'POST /api/v1/routing/ipv4-scan') {
|
||||
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
|
||||
}
|
||||
if (key === 'GET /api/v1/host-history' || key === 'GET /api/v1/containers/{id}/history') {
|
||||
notes.push('Metrics are collected in the background every 30 seconds, even when the statistics page is closed.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/public-ipv4' || key === 'PUT /api/v1/containers/{id}/ipv6-addresses') {
|
||||
notes.push('mode accepts random, custom, or clear. random uses count, custom uses addresses, and clear removes all assignments of that address family.')
|
||||
}
|
||||
if (key === 'GET /api/v1/images/enabled?type=lxc&container={id}') {
|
||||
notes.push('type accepts lxc or kvm. Supplying container applies that container image allowlist; omit container when listing images for a new container.')
|
||||
}
|
||||
if (key === 'POST /api/v1/containers/{id}/snapshots') {
|
||||
notes.push('storage_pool_id is optional. The selected pool must be enabled for snapshots; otherwise the server chooses an available snapshot pool by free space and default priority.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/storage') {
|
||||
notes.push('Start from GET /api/v1/storage and submit mounted disks returned by the server. Paths and mount points are server-managed and custom paths are rejected. content_types enables a disk for each workload; only one pool may be the default for each type.')
|
||||
}
|
||||
if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins') || key.includes('/access-policy')) {
|
||||
notes.push('This endpoint requires an API key with admin:access.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/access-policy') {
|
||||
notes.push('allowed_sources and trusted_proxies accept IPv4, IPv6, or CIDR values. Forwarded client headers are ignored unless the direct peer matches trusted_proxies. The server rejects an enabled policy that excludes the current source.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/task-queue/settings') {
|
||||
notes.push('concurrency must be between 1 and 16. Tasks targeting the same container are still serialized.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/ssl') {
|
||||
notes.push('mode accepts disabled, letsencrypt, self_signed, or uploaded. uploaded mode uses cert_pem and key_pem. apply_now requests a service restart after saving.')
|
||||
}
|
||||
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
@@ -44,13 +44,17 @@ import {
|
||||
getContainerSnapshots,
|
||||
getContainerUsage,
|
||||
getHostInfo,
|
||||
getStorageInfo,
|
||||
getTrafficInfo,
|
||||
HostInfo,
|
||||
TrafficInfo,
|
||||
getEnabledImages,
|
||||
getFirewall,
|
||||
PortMapping,
|
||||
PublicIPv4Info,
|
||||
FirewallRule,
|
||||
updatePublicIPv4Assignments,
|
||||
updateIPv6Assignments,
|
||||
reinstallContainer,
|
||||
resetSSHPassword,
|
||||
restartContainer,
|
||||
@@ -58,6 +62,7 @@ import {
|
||||
stopContainer,
|
||||
Snapshot,
|
||||
SnapshotSchedule,
|
||||
StorageInfo,
|
||||
Template,
|
||||
updateContainerExpiry,
|
||||
updateFirewall,
|
||||
@@ -72,6 +77,7 @@ import {
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import WebSSHViewer from '../components/WebSSHViewer'
|
||||
import WebVNCViewer from '../components/WebVNCViewer'
|
||||
import { RingStat } from '../components/RingStats'
|
||||
@@ -107,6 +113,8 @@ type MappingDraft = {
|
||||
protocol: string
|
||||
}
|
||||
|
||||
type IPAssignMode = 'clear' | 'random' | 'custom'
|
||||
|
||||
const emptyDraft: MappingDraft = {
|
||||
index: null,
|
||||
description: '',
|
||||
@@ -122,6 +130,7 @@ export default function ContainerDetail() {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const { isSubUser } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [container, setContainer] = useState<Container | null>(null)
|
||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||
const [usage, setUsage] = useState<ContainerUsage | null>(null)
|
||||
@@ -135,6 +144,14 @@ export default function ContainerDetail() {
|
||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||
const [showNat, setShowNat] = useState(false)
|
||||
const [showIPAssign, setShowIPAssign] = useState(false)
|
||||
const [savingIPAssign, setSavingIPAssign] = useState(false)
|
||||
const [ipv4AssignMode, setIPv4AssignMode] = useState<IPAssignMode>('clear')
|
||||
const [ipv4AssignCount, setIPv4AssignCount] = useState(1)
|
||||
const [ipv4Selected, setIPv4Selected] = useState<string[]>([])
|
||||
const [ipv6AssignMode, setIPv6AssignMode] = useState<IPAssignMode>('clear')
|
||||
const [ipv6AssignCount, setIPv6AssignCount] = useState(1)
|
||||
const [ipv6DraftText, setIPv6DraftText] = useState('')
|
||||
const [showMappingEditor, setShowMappingEditor] = useState(false)
|
||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||
const [editExpiry, setEditExpiry] = useState('')
|
||||
@@ -169,6 +186,9 @@ export default function ContainerDetail() {
|
||||
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
||||
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
||||
const [snapshotBusy, setSnapshotBusy] = useState('')
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [storageLoading, setStorageLoading] = useState(!isSubUser)
|
||||
const [snapshotStoragePoolID, setSnapshotStoragePoolID] = useState('')
|
||||
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||
const [showFirewall, setShowFirewall] = useState(false)
|
||||
@@ -211,6 +231,23 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}, [containerIdentifier, container?.snapshot_limit])
|
||||
|
||||
const fetchStorage = useCallback(async () => {
|
||||
if (isSubUser) {
|
||||
setStorageLoading(false)
|
||||
return
|
||||
}
|
||||
setStorageLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
setStorageInfo(res.data.data || null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch storage:', err)
|
||||
setStorageInfo(null)
|
||||
} finally {
|
||||
setStorageLoading(false)
|
||||
}
|
||||
}, [isSubUser])
|
||||
|
||||
const fetchMetricHistory = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
try {
|
||||
@@ -284,8 +321,11 @@ export default function ContainerDetail() {
|
||||
}, [fetchMetricHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSnapshots) fetchSnapshots()
|
||||
}, [showSnapshots, fetchSnapshots])
|
||||
if (showSnapshots) {
|
||||
fetchSnapshots()
|
||||
fetchStorage()
|
||||
}
|
||||
}, [showSnapshots, fetchSnapshots, fetchStorage])
|
||||
|
||||
// Poll task status for this container
|
||||
useEffect(() => {
|
||||
@@ -630,6 +670,42 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const openIPAssign = () => {
|
||||
const currentIPv4 = (container?.public_ipv4s || []).map((item) => item.address).filter(Boolean)
|
||||
const currentIPv6 = (container?.ipv6_addresses || []).map((item) => item.address).filter(Boolean)
|
||||
setIPv4Selected(currentIPv4)
|
||||
setIPv4AssignMode(currentIPv4.length > 0 ? 'custom' : 'clear')
|
||||
setIPv4AssignCount(Math.max(1, currentIPv4.length || 1))
|
||||
setIPv6DraftText(currentIPv6.join('\n'))
|
||||
setIPv6AssignMode(currentIPv6.length > 0 ? 'custom' : 'clear')
|
||||
setIPv6AssignCount(Math.max(1, currentIPv6.length || 1))
|
||||
setShowIPAssign(true)
|
||||
}
|
||||
|
||||
const submitIPAssign = async () => {
|
||||
if (!containerIdentifier) return
|
||||
setSavingIPAssign(true)
|
||||
try {
|
||||
await updatePublicIPv4Assignments(containerIdentifier, {
|
||||
mode: ipv4AssignMode,
|
||||
count: Math.max(1, Math.round(ipv4AssignCount || 1)),
|
||||
addresses: ipv4AssignMode === 'custom' ? ipv4Selected : [],
|
||||
})
|
||||
await updateIPv6Assignments(containerIdentifier, {
|
||||
mode: ipv6AssignMode,
|
||||
count: Math.max(1, Math.round(ipv6AssignCount || 1)),
|
||||
addresses: ipv6AssignMode === 'custom' ? splitAddressLines(ipv6DraftText) : [],
|
||||
})
|
||||
await fetchContainer()
|
||||
setShowIPAssign(false)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('公网 IP 分配失败', error.response?.data?.message || '请检查地址是否可用或已被占用')
|
||||
} finally {
|
||||
setSavingIPAssign(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAddMapping = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
setDraft(emptyDraft)
|
||||
@@ -725,6 +801,10 @@ export default function ContainerDetail() {
|
||||
const handleCreateSnapshot = async () => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (!snapshotStorageReady) {
|
||||
await dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||
return
|
||||
}
|
||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||
return
|
||||
@@ -738,7 +818,7 @@ export default function ContainerDetail() {
|
||||
}
|
||||
setSnapshotBusy('create')
|
||||
try {
|
||||
await createContainerSnapshot(containerIdentifier)
|
||||
await createContainerSnapshot(containerIdentifier, { storage_pool_id: snapshotStoragePoolID || undefined })
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
@@ -750,6 +830,10 @@ export default function ContainerDetail() {
|
||||
|
||||
const openSnapshotSchedule = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
if (!snapshotStorageReady) {
|
||||
dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||
return
|
||||
}
|
||||
setSnapshotScheduleDraft({
|
||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||
time: snapshotSchedule?.time || '03:00',
|
||||
@@ -864,6 +948,7 @@ export default function ContainerDetail() {
|
||||
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
||||
const publicIPv4s = container.public_ipv4s || []
|
||||
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
|
||||
const allocatableIPv4s = mergeIPv4Choices(hostInfo?.network.public_ipv4_addresses || [], publicIPv4s)
|
||||
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||
const ipv6List = (container.ipv6_addresses || [])
|
||||
.map((item) => item.address)
|
||||
@@ -874,6 +959,10 @@ export default function ContainerDetail() {
|
||||
const hasIndependentIPv4 = assignedIPv4List.length > 0
|
||||
const hasIndependentIPv6 = ipv6List.length > 0
|
||||
const defaultConnPort = isWindows ? 3389 : 22
|
||||
const snapshotStoragePools = (storageInfo?.pools || []).filter((pool) =>
|
||||
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('snapshots')
|
||||
)
|
||||
const snapshotStorageReady = isSubUser || snapshotStoragePools.length > 0
|
||||
|
||||
let publicEndpoint = '-'
|
||||
let sshCommand = ''
|
||||
@@ -1181,22 +1270,35 @@ export default function ContainerDetail() {
|
||||
<PlainRow label="vCPU" value={`${container.vcpu} 核`} />
|
||||
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
|
||||
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
|
||||
<PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
|
||||
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
|
||||
<PlainRow label="网络速率" value={formatDirectionalLimit(t('下行'), networkDownLimit, t('上行'), networkUpLimit, 'Mbps')} />
|
||||
<PlainRow label="IO 速度" value={formatDirectionalLimit(t('读取'), ioReadLimit, t('写入'), ioWriteLimit, 'MB/s')} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="实时状态">
|
||||
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
|
||||
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
|
||||
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
|
||||
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
|
||||
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
||||
{!isSubUser && ipv6List.length === 0 && (
|
||||
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||
Assign
|
||||
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText}>
|
||||
{!isSubUser && (
|
||||
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</PlainRow>
|
||||
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
||||
{!isSubUser && (
|
||||
<>
|
||||
{ipv6List.length === 0 && (
|
||||
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||
Assign
|
||||
</button>
|
||||
)}
|
||||
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PlainRow>
|
||||
<PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
|
||||
<PlainRow label="创建时间" value={container.created_at} />
|
||||
<PlainRow label="到期时间" value={formatExpiration(container.expires_at)}>
|
||||
@@ -1429,7 +1531,7 @@ export default function ContainerDetail() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSnapshotSchedule}
|
||||
disabled={!!snapshotBusy}
|
||||
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
|
||||
snapshotSchedule?.enabled
|
||||
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||
@@ -1441,7 +1543,7 @@ export default function ContainerDetail() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateSnapshot}
|
||||
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
@@ -1451,6 +1553,20 @@ export default function ContainerDetail() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{storageLoading && !isSubUser && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
正在检查存储配置...
|
||||
</div>
|
||||
)}
|
||||
{!storageLoading && !snapshotStorageReady && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<span>尚未开启快照存储,无法新建或启用定时快照。</span>
|
||||
<button onClick={() => { setShowSnapshots(false); navigate('/storage') }} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
|
||||
去开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
|
||||
<div>
|
||||
快照数量:
|
||||
@@ -1486,6 +1602,26 @@ export default function ContainerDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSubUser && snapshotStoragePools.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<Field label="新建快照存储磁盘">
|
||||
<select
|
||||
value={snapshotStoragePoolID}
|
||||
onChange={(event) => setSnapshotStoragePoolID(event.target.value)}
|
||||
className="w-72 px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black"
|
||||
>
|
||||
<option value="">自动选择(默认盘优先,空间不足自动切换)</option>
|
||||
{snapshotStoragePools.map((pool) => (
|
||||
<option key={pool.id} value={pool.id}>
|
||||
{pool.name} · {pool.mount_point || pool.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="pb-2 text-xs text-gray-400">仅影响手动新建快照;定时快照使用默认磁盘。</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingSnapshotQuota && !isSubUser && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<Field label="子用户每台容器快照上限">
|
||||
@@ -1817,6 +1953,88 @@ export default function ContainerDetail() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showIPAssign && (
|
||||
<Modal title="公网 IP 分配" onClose={() => setShowIPAssign(false)} wide>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">独立 IPv4</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">修改后会重放端口映射、SNAT 和防火墙规则。</p>
|
||||
</div>
|
||||
<Segmented value={ipv4AssignMode} onChange={setIPv4AssignMode} />
|
||||
{ipv4AssignMode === 'random' && (
|
||||
<Field label="随机数量">
|
||||
<input type="number" min={1} max={64} value={ipv4AssignCount} onChange={(e) => setIPv4AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
|
||||
</Field>
|
||||
)}
|
||||
{ipv4AssignMode === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
{allocatableIPv4s.length === 0 ? (
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{allocatableIPv4s.map((ip) => (
|
||||
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-xs text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ipv4Selected.includes(ip.address)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? Array.from(new Set([...ipv4Selected, ip.address]))
|
||||
: ipv4Selected.filter((value) => value !== ip.address)
|
||||
setIPv4Selected(next)
|
||||
setIPv4AssignCount(Math.max(1, next.length || 1))
|
||||
}}
|
||||
/>
|
||||
<span className="truncate font-mono">{ip.address}</span>
|
||||
<span className="shrink-0 text-gray-400">{ip.interface}</span>
|
||||
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">独立 IPv6</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">自定义地址必须落在路由管理配置的 IPv6 前缀内。</p>
|
||||
</div>
|
||||
<Segmented value={ipv6AssignMode} onChange={setIPv6AssignMode} />
|
||||
{ipv6AssignMode === 'random' && (
|
||||
<Field label="随机数量">
|
||||
<input type="number" min={1} max={64} value={ipv6AssignCount} onChange={(e) => setIPv6AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
|
||||
</Field>
|
||||
)}
|
||||
{ipv6AssignMode === 'custom' && (
|
||||
<Field label="IPv6 地址">
|
||||
<textarea
|
||||
value={ipv6DraftText}
|
||||
onChange={(e) => {
|
||||
setIPv6DraftText(e.target.value)
|
||||
setIPv6AssignCount(Math.max(1, splitAddressLines(e.target.value).length || 1))
|
||||
}}
|
||||
className={`${inputClass} min-h-32 font-mono text-xs`}
|
||||
placeholder="2001:db8:100::100 2001:db8:100::101"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2 border-t border-gray-200 pt-4">
|
||||
<button onClick={() => setShowIPAssign(false)} disabled={savingIPAssign} className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={submitIPAssign} disabled={savingIPAssign} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-4 w-4" />
|
||||
{savingIPAssign ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showNat && !hasIndependentIPv4 && (
|
||||
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||
!isSubUser && canAddMapping && (
|
||||
@@ -2442,6 +2660,28 @@ function Field({ label, children, hint }: { label: string; children: ReactNode;
|
||||
)
|
||||
}
|
||||
|
||||
function Segmented({ value, onChange }: { value: IPAssignMode; onChange: (value: IPAssignMode) => void }) {
|
||||
const items: Array<{ value: IPAssignMode; label: string }> = [
|
||||
{ value: 'clear', label: '不分配' },
|
||||
{ value: 'random', label: '随机分配' },
|
||||
{ value: 'custom', label: '自定义' },
|
||||
]
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1 rounded-md bg-gray-100 p-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => onChange(item.value)}
|
||||
className={`rounded px-2 py-1.5 text-xs font-medium ${value === item.value ? 'bg-white text-black shadow-sm' : 'text-gray-600 hover:text-black'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
@@ -2493,6 +2733,31 @@ function normalizeContainerMetricSample(point: ContainerMetricSample): MetricPoi
|
||||
}
|
||||
}
|
||||
|
||||
function splitAddressLines(value: string) {
|
||||
return value
|
||||
.split(/[\n,,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function mergeIPv4Choices(candidates: PublicIPv4Info[], assigned: { address: string; interface?: string; prefix_len?: number; gateway?: string }[]) {
|
||||
const byAddress = new Map<string, PublicIPv4Info>()
|
||||
for (const item of candidates) {
|
||||
if (item.address) byAddress.set(item.address, item)
|
||||
}
|
||||
for (const item of assigned) {
|
||||
if (!item.address || byAddress.has(item.address)) continue
|
||||
byAddress.set(item.address, {
|
||||
address: item.address,
|
||||
interface: item.interface || '',
|
||||
prefix: item.prefix_len ? `${item.address}/${item.prefix_len}` : item.address,
|
||||
prefix_len: item.prefix_len,
|
||||
gateway: item.gateway,
|
||||
})
|
||||
}
|
||||
return Array.from(byAddress.values()).sort((a, b) => a.address.localeCompare(b.address, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
function historyKey(containerName: string) {
|
||||
return `clicd_container_metric_history:${containerName}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import CreateContainerModal from '../components/CreateContainerModal'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import {
|
||||
Container,
|
||||
CreateContainerRequest,
|
||||
@@ -391,7 +392,7 @@ export default function Containers() {
|
||||
{pageContainers.map((container) => {
|
||||
const isRunning = container.status === 'running'
|
||||
const isInitializing = container.status === 'initializing'
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : undefined) || taskNameMap[container.name] || container.createTask
|
||||
const isPlaceholder = !!container.isPlaceholder
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
const usage = usageByName[container.name]
|
||||
@@ -581,12 +582,13 @@ type DisplayContainer = Container & {
|
||||
}
|
||||
|
||||
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
||||
const { t } = useLanguage()
|
||||
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
|
||||
if (policyBlocked) {
|
||||
return (
|
||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
策略封禁
|
||||
{t('策略封禁')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -595,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
初始化失败
|
||||
{t('初始化失败')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -604,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
初始化完成
|
||||
{t('初始化完成')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (task?.type === 'create' && task.status === 'running') {
|
||||
const detail = t(task.stage_detail || '正在初始化')
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
<span className={`${baseClass} max-w-[210px] bg-amber-50 text-amber-700`} title={`${t('正在初始化')}: ${detail}`}>
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
<span className="truncate">{detail}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -622,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||
排队等待
|
||||
{t('排队等待')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -634,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
{taskLabels[task.type] || '处理中'}
|
||||
{t(taskLabels[task.type] || '处理中')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -643,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
{t('正在初始化')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -651,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{running ? '在线' : '离线'}
|
||||
{t(running ? '在线' : '离线')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -788,7 +791,7 @@ type ContainerFilters = {
|
||||
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
|
||||
const keyword = filters.search.trim().toLowerCase()
|
||||
return containers.filter((container) => {
|
||||
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
|
||||
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : undefined) || filters.taskNameMap[container.name] || container.createTask
|
||||
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
|
||||
return false
|
||||
}
|
||||
@@ -865,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task)
|
||||
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
||||
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
||||
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
||||
if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化'
|
||||
return actionLabels[task.type] || '处理中...'
|
||||
}
|
||||
|
||||
@@ -873,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
onRefresh: () => void | Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex max-h-[86vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-black">任务队列</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500">共 {tasks.length} 个任务</p>
|
||||
<h2 className="text-base font-semibold text-black">{t('任务队列')}</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{t(`共 ${tasks.length} 个任务`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -887,26 +892,27 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
{t('刷新')}
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
|
||||
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title={t('关闭')}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-500">暂无任务</div>
|
||||
<div className="p-8 text-center text-sm text-gray-500">{t('暂无任务')}</div>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
|
||||
<th className="whitespace-nowrap px-4 py-2.5">状态</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">操作</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">容器</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">创建时间</th>
|
||||
<th className="px-4 py-2.5">错误</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('状态')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('操作')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('容器')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('当前阶段')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('创建时间')}</th>
|
||||
<th className="px-4 py-2.5">{t('错误')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -915,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
<tr key={task.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-2.5">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
|
||||
{taskStatusLabel(task.status)}
|
||||
{t(taskStatusLabel(task.status))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{t(actionLabel(task.type))}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
|
||||
<td className="min-w-[210px] px-4 py-2.5 text-xs text-gray-700">
|
||||
{task.type === 'create' ? t(task.stage_detail || (task.status === 'pending' ? '排队等待' : '-')) : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
|
||||
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
|
||||
<td className="whitespace-nowrap px-2 py-2.5">
|
||||
@@ -932,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="取消任务"
|
||||
title={t('取消任务')}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -982,6 +991,7 @@ function getTemplateName(id: string) {
|
||||
'kvm-debian-bookworm': 'Debian 12',
|
||||
'kvm-debian-bullseye': 'Debian 11',
|
||||
'kvm-rockylinux-9': 'Rocky 9',
|
||||
'kvm-windows-11': 'Windows 11',
|
||||
'kvm-windows-10': 'Windows 10',
|
||||
}
|
||||
return map[id] || id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Cpu, HardDrive, MemoryStick, Network, Server } from 'lucide-react'
|
||||
import RingStats from '../components/RingStats'
|
||||
import ResourceStatsPanel, {
|
||||
@@ -171,7 +171,7 @@ function SummaryCard({
|
||||
value,
|
||||
muted = false,
|
||||
}: {
|
||||
icon?: JSX.Element
|
||||
icon?: ReactNode
|
||||
dot?: string
|
||||
title: string
|
||||
value: number
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
@@ -10,16 +11,37 @@ import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
Plus,
|
||||
Unlink,
|
||||
CloudDownload,
|
||||
} from 'lucide-react'
|
||||
import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
||||
import {
|
||||
getImages,
|
||||
getStorageInfo,
|
||||
downloadImage,
|
||||
cancelImageDownload,
|
||||
deleteImage,
|
||||
toggleImage,
|
||||
createCustomKVMImage,
|
||||
removeCustomKVMImage,
|
||||
ImageInfo,
|
||||
StorageInfo,
|
||||
CustomKVMImageInput,
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
export default function ImageManagement() {
|
||||
const dialog = useDialog()
|
||||
const { t } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const [images, setImages] = useState<ImageInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [storageLoading, setStorageLoading] = useState(true)
|
||||
const [customModalOpen, setCustomModalOpen] = useState<'lxc' | 'kvm' | null>(null)
|
||||
|
||||
const fetchImages = useCallback(async () => {
|
||||
try {
|
||||
@@ -33,9 +55,22 @@ export default function ImageManagement() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchStorage = useCallback(async () => {
|
||||
setStorageLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
setStorageInfo(res.data.data || null)
|
||||
} catch {
|
||||
setStorageInfo(null)
|
||||
} finally {
|
||||
setStorageLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages()
|
||||
}, [fetchImages])
|
||||
fetchStorage()
|
||||
}, [fetchImages, fetchStorage])
|
||||
|
||||
useEffect(() => {
|
||||
const hasDownloads = images.some((img) => img.downloading)
|
||||
@@ -98,9 +133,40 @@ export default function ImageManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveCustom = async (templateId: string) => {
|
||||
if (!(await dialog.confirm('移除第三方镜像', '确定移除该镜像源和已下载的缓存吗?正在使用该镜像的虚拟机不会允许移除。'))) return
|
||||
setActionLoading(templateId)
|
||||
setError('')
|
||||
try {
|
||||
await removeCustomKVMImage(templateId)
|
||||
await fetchImages()
|
||||
dialog.alert('完成', '第三方镜像已移除')
|
||||
} catch (err: unknown) {
|
||||
dialog.alert('失败', apiErrorMessage(err, '移除第三方镜像失败'))
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomCreated = async (payload: CustomKVMImageInput) => {
|
||||
const response = await createCustomKVMImage(payload)
|
||||
const image = response.data.data
|
||||
if (!image) throw new Error('镜像源保存成功,但服务器没有返回镜像 ID')
|
||||
try {
|
||||
await downloadImage(image.id)
|
||||
dialog.alert('完成', '第三方镜像已添加,下载任务已启动')
|
||||
} catch (err: unknown) {
|
||||
dialog.alert('提示', `镜像源已保存,但下载未启动:${apiErrorMessage(err, '请在列表中重试')}`)
|
||||
}
|
||||
await fetchImages()
|
||||
}
|
||||
|
||||
const downloadedCount = images.filter((img) => img.downloaded).length
|
||||
const lxcImages = images.filter((img) => img.type === 'lxc')
|
||||
const kvmImages = images.filter((img) => img.type === 'kvm')
|
||||
const imageStorageReady = (storageInfo?.pools || []).some((pool) =>
|
||||
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('images')
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -121,7 +187,7 @@ export default function ImageManagement() {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchImages}
|
||||
onClick={() => { fetchImages(); fetchStorage() }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
@@ -136,6 +202,25 @@ export default function ImageManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageLoading && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||||
正在检查存储配置...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!storageLoading && !imageStorageReady && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
尚未开启镜像缓存存储,无法下载新镜像。
|
||||
</div>
|
||||
<button onClick={() => navigate('/storage')} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
|
||||
去开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImageTable
|
||||
title="LXC 容器镜像"
|
||||
images={lxcImages}
|
||||
@@ -146,6 +231,21 @@ export default function ImageManagement() {
|
||||
onCancelDownload={handleCancelDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
onRemoveCustom={handleRemoveCustom}
|
||||
storageReady={imageStorageReady}
|
||||
storageLoading={storageLoading}
|
||||
headerAction={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomModalOpen('lxc')}
|
||||
disabled={storageLoading || !imageStorageReady}
|
||||
title={storageLoading ? t('正在检查存储配置...') : imageStorageReady ? t('下载第三方 LXC 镜像') : t('请先在存储管理中开启镜像缓存存储')}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-200"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('第三方镜像')}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{kvmImages.length > 0 && (
|
||||
@@ -159,8 +259,206 @@ export default function ImageManagement() {
|
||||
onCancelDownload={handleCancelDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
onRemoveCustom={handleRemoveCustom}
|
||||
storageReady={imageStorageReady}
|
||||
storageLoading={storageLoading}
|
||||
headerAction={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomModalOpen('kvm')}
|
||||
disabled={storageLoading || !imageStorageReady}
|
||||
title={storageLoading ? t('正在检查存储配置...') : imageStorageReady ? t('下载第三方 KVM 镜像') : t('请先在存储管理中开启镜像缓存存储')}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-200"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('第三方镜像')}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{customModalOpen !== null && (
|
||||
<CustomKVMImageModal
|
||||
virtualization={customModalOpen}
|
||||
arch={(customModalOpen === 'lxc' ? lxcImages[0]?.arch : kvmImages[0]?.arch) || 'amd64'}
|
||||
onClose={() => setCustomModalOpen(null)}
|
||||
onSubmit={handleCustomCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyCustomImage = (arch: string, virtualization: 'lxc' | 'kvm'): CustomKVMImageInput => ({
|
||||
type: virtualization,
|
||||
name: '',
|
||||
description: '',
|
||||
distro: '',
|
||||
release: '',
|
||||
arch,
|
||||
url: '',
|
||||
provisioner: virtualization === 'lxc' ? 'lxc-rootfs' : 'linux-cloud-init',
|
||||
sha256: '',
|
||||
})
|
||||
|
||||
function CustomKVMImageModal({
|
||||
virtualization,
|
||||
arch,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
virtualization: 'lxc' | 'kvm'
|
||||
arch: string
|
||||
onClose: () => void
|
||||
onSubmit: (payload: CustomKVMImageInput) => Promise<void>
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [form, setForm] = useState<CustomKVMImageInput>(() => emptyCustomImage(arch, virtualization))
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const windows = virtualization === 'kvm' && form.provisioner !== 'linux-cloud-init'
|
||||
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !submitting) onClose()
|
||||
}
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||
}, [onClose, submitting])
|
||||
|
||||
const updateProvisioner = (provisioner: 'linux-cloud-init' | 'windows-10' | 'windows-11') => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
provisioner,
|
||||
distro: provisioner === 'linux-cloud-init' ? (current.distro === 'windows' ? '' : current.distro) : 'windows',
|
||||
release: provisioner === 'windows-10' ? '10' : provisioner === 'windows-11' ? '11' : (current.distro === 'windows' ? '' : current.release),
|
||||
}))
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.distro.trim() || !form.release.trim() || !form.url.trim()) {
|
||||
setFormError(t('请填写名称、发行版、版本和下载地址'))
|
||||
return
|
||||
}
|
||||
if (form.sha256 && !/^[a-fA-F0-9]{64}$/.test(form.sha256.trim())) {
|
||||
setFormError(t('SHA-256 必须是 64 位十六进制字符串'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setFormError('')
|
||||
try {
|
||||
await onSubmit({
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
distro: form.distro.trim().toLowerCase(),
|
||||
release: form.release.trim().toLowerCase(),
|
||||
url: form.url.trim(),
|
||||
sha256: form.sha256?.trim().toLowerCase(),
|
||||
})
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
setFormError(apiErrorMessage(err, t('添加第三方镜像失败')))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = 'mt-1.5 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black outline-none focus:border-black focus:ring-2 focus:ring-black/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:focus:border-white dark:focus:ring-white/10'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/55 p-4 dark:bg-black/75">
|
||||
<div className="w-full max-w-2xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{t(virtualization === 'lxc' ? '下载第三方 LXC 镜像' : '下载第三方 KVM 镜像')}</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(virtualization === 'lxc' ? '支持 tar、tar.gz、tar.xz、tar.zst 格式的 Linux rootfs' : '镜像格式必须与所选无人值守安装模板匹配')}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} disabled={submitting} className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-black disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[72vh] space-y-5 overflow-y-auto px-5 py-4">
|
||||
{virtualization === 'kvm' && <div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-200">{t('无人值守安装模板')}</label>
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{([
|
||||
['linux-cloud-init', 'Linux cloud-init', 'QCOW2 / IMG'],
|
||||
['windows-10', 'Windows 10', '安装 ISO'],
|
||||
['windows-11', 'Windows 11', '安装 ISO'],
|
||||
] as const).map(([value, label, hint]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => updateProvisioner(value)}
|
||||
disabled={arch !== 'amd64' && value !== 'linux-cloud-init'}
|
||||
className={`rounded-md border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
form.provisioner === value
|
||||
? 'border-black bg-gray-50 dark:border-white dark:bg-gray-800'
|
||||
: 'border-gray-200 hover:border-gray-400 dark:border-gray-700 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-sm font-medium text-gray-900 dark:text-white">{label}</span>
|
||||
<span className="mt-0.5 block text-xs text-gray-500 dark:text-gray-400">{t(hint)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{t('镜像名称')}
|
||||
<input className={inputClass} value={form.name} maxLength={100} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={virtualization === 'lxc' ? 'Alpine Custom Rootfs' : windows ? 'Windows 11 Custom' : 'Ubuntu Custom Cloud'} />
|
||||
</label>
|
||||
<label className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{t('架构')}
|
||||
<select className={inputClass} value={form.arch} disabled>
|
||||
<option value={arch}>{arch}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{t('发行版')}
|
||||
<input className={inputClass} value={form.distro} disabled={windows} maxLength={64} onChange={(event) => setForm({ ...form, distro: event.target.value })} placeholder="ubuntu" />
|
||||
</label>
|
||||
<label className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{t('版本 / 代号')}
|
||||
<input className={inputClass} value={form.release} disabled={windows} maxLength={64} onChange={(event) => setForm({ ...form, release: event.target.value })} placeholder="noble" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm text-gray-700 dark:text-gray-200">
|
||||
{t('备注')}
|
||||
<textarea className={`${inputClass} min-h-20 resize-y`} value={form.description} maxLength={500} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder={t('镜像来源、版本或用途')} />
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-gray-700 dark:text-gray-200">
|
||||
{t('镜像下载地址')}
|
||||
<input className={`${inputClass} font-mono text-xs`} value={form.url} onChange={(event) => setForm({ ...form, url: event.target.value })} placeholder={virtualization === 'lxc' ? 'https://example.com/rootfs.tar.xz' : windows ? 'https://example.com/windows.iso' : 'https://example.com/image.qcow2'} />
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-gray-700 dark:text-gray-200">
|
||||
SHA-256 <span className="text-xs text-gray-400">({t('可选')})</span>
|
||||
<input className={`${inputClass} font-mono text-xs`} value={form.sha256 || ''} maxLength={64} onChange={(event) => setForm({ ...form, sha256: event.target.value })} placeholder={t('用于校验下载文件完整性')} />
|
||||
</label>
|
||||
|
||||
{formError && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-gray-200 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<button type="button" onClick={onClose} disabled={submitting} className="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-700">{t('取消')}</button>
|
||||
<button type="button" onClick={submit} disabled={submitting} className="inline-flex items-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-200">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
|
||||
{submitting ? t('正在添加...') : t('添加并下载')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -175,6 +473,10 @@ function ImageTable({
|
||||
onCancelDownload,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onRemoveCustom,
|
||||
storageReady,
|
||||
storageLoading,
|
||||
headerAction,
|
||||
}: {
|
||||
title: string
|
||||
images: ImageInfo[]
|
||||
@@ -185,14 +487,21 @@ function ImageTable({
|
||||
onCancelDownload: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
onRemoveCustom: (id: string) => void
|
||||
storageReady: boolean
|
||||
storageLoading: boolean
|
||||
headerAction?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-800">{title}</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
已下载 {downloadedCount}/{totalCount}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100">{title}</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
已下载 {downloadedCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -227,10 +536,11 @@ function ImageTable({
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 flex items-center justify-center flex-shrink-0">
|
||||
{getTemplateIcon(img.id)}
|
||||
{getTemplateIcon(img.id, img.distro, img.custom)}
|
||||
</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
|
||||
<span className="font-medium text-gray-900 text-sm dark:text-gray-100">{img.name}</span>
|
||||
{img.custom && <span className="ml-2 rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">第三方</span>}
|
||||
<p className="text-[11px] text-gray-400">{img.description}</p>
|
||||
|
||||
</div>
|
||||
@@ -253,7 +563,8 @@ function ImageTable({
|
||||
{!img.downloaded && !img.downloading && (
|
||||
<button
|
||||
onClick={() => onDownload(img.id)}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || storageLoading || !storageReady}
|
||||
title={storageLoading ? '正在检查存储配置...' : storageReady ? '下载镜像' : '请先在存储管理中开启镜像缓存存储'}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
{isBusy ? (
|
||||
@@ -281,7 +592,8 @@ function ImageTable({
|
||||
<>
|
||||
<button
|
||||
onClick={() => onToggle(img.id, img.enabled)}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || storageLoading || !storageReady}
|
||||
title={storageLoading ? '正在检查存储配置...' : storageReady ? (img.enabled ? '禁用镜像' : '启用镜像') : '请先在存储管理中开启镜像缓存存储'}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
img.enabled
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
|
||||
@@ -301,6 +613,16 @@ function ImageTable({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{img.custom && !img.downloading && (
|
||||
<button
|
||||
onClick={() => onRemoveCustom(img.id)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center rounded-md border border-gray-200 p-1.5 text-gray-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 disabled:opacity-50 dark:border-gray-700 dark:text-gray-400 dark:hover:border-red-900 dark:hover:bg-red-950 dark:hover:text-red-300"
|
||||
title="移除第三方镜像源和缓存"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -317,7 +639,7 @@ function ImageTable({
|
||||
function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
if (img.downloading) {
|
||||
const progress = Math.max(0, Math.min(100, img.progress || 0))
|
||||
const showProgress = img.stage === 'downloading' && progress > 0
|
||||
const showProgress = img.stage === 'downloading' && (progress > 0 || img.downloaded_bytes > 0)
|
||||
return (
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span
|
||||
@@ -329,7 +651,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
</span>
|
||||
{showProgress && (
|
||||
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
|
||||
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
|
||||
<span
|
||||
className={`block h-full rounded-full bg-amber-500 transition-all ${progress <= 0 ? 'animate-pulse' : ''}`}
|
||||
style={{ width: progress > 0 ? `${progress}%` : '35%' }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -373,8 +698,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
function downloadStatusLabel(img: ImageInfo) {
|
||||
if (img.stage === 'canceling') return '取消中'
|
||||
if (img.stage === 'converting') return '转换中'
|
||||
if (img.stage === 'validating') return '校验中'
|
||||
if (img.stage === 'lxc-create') return '下载中'
|
||||
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
|
||||
if (img.downloaded_bytes > 0) return `下载中 · ${formatSize(img.downloaded_bytes)}`
|
||||
return '下载中'
|
||||
}
|
||||
|
||||
@@ -391,8 +718,9 @@ function isWindowsImage(img: ImageInfo) {
|
||||
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
|
||||
}
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
function getTemplateIcon(id: string, distro = '', custom = false): ReactNode {
|
||||
const size = 'w-5 h-5'
|
||||
id = (custom && distro ? distro : id).toLowerCase()
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||
@@ -402,6 +730,7 @@ function getTemplateIcon(id: string): ReactNode {
|
||||
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||
if (custom) return <CloudDownload className={`${size} text-blue-600 dark:text-blue-300`} />
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,12 @@ export default function Login() {
|
||||
await login(username, password)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setError(error.response?.data?.message || t('登录失败,请检查用户名和密码'))
|
||||
const error = err as { response?: { status?: number; data?: { message?: string } } }
|
||||
if (error.response?.status === 401) {
|
||||
setError(t(isAccessCodeLogin ? '访问码或密码错误' : '用户名或密码错误'))
|
||||
} else {
|
||||
setError(error.response?.data?.message || t('登录失败,请检查用户名和密码'))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -79,7 +83,7 @@ export default function Login() {
|
||||
{!isAccessCodeLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
用户名
|
||||
{t('用户名')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
@@ -90,7 +94,7 @@ export default function Login() {
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-md text-black bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-black focus:border-black text-sm"
|
||||
placeholder="输入用户名"
|
||||
placeholder={t('输入用户名')}
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
@@ -100,7 +104,7 @@ export default function Login() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
密码
|
||||
{t('密码')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
@@ -111,7 +115,7 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-md text-black bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-black focus:border-black text-sm"
|
||||
placeholder="输入密码"
|
||||
placeholder={t('输入密码')}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
@@ -123,12 +127,12 @@ export default function Login() {
|
||||
disabled={loading}
|
||||
className="w-full bg-black text-white py-2.5 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
{loading ? t('登录中...') : t('登录')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.24</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.28</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Globe2, Network, Pencil, Plus, RefreshCw, Router, Save, Search, Server, Trash2, X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import {
|
||||
getRoutingInfo,
|
||||
@@ -62,6 +62,7 @@ export default function Routing() {
|
||||
const ipv6Prefixes = routing?.ipv6_prefixes || []
|
||||
const ipv6Assignments = routing?.ipv6_assignments || []
|
||||
const nat4Range = routing?.nat4_port_range || { start: 20000, end: 65535 }
|
||||
const nat4Networks = routing?.nat4_networks
|
||||
const defaultIPv4Interface = routing?.host_public_ipv4?.interface || publicIPv4s[0]?.interface || 'eth0'
|
||||
const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || ''
|
||||
const defaultIPv4PrefixLen = routing?.host_public_ipv4?.prefix_len || publicIPv4s[0]?.prefix_len || 32
|
||||
@@ -287,7 +288,10 @@ export default function Routing() {
|
||||
used={routing?.nat4.used || 0}
|
||||
label={text.remainingTotal}
|
||||
usedLabel={text.used}
|
||||
detail={formatNATRange(nat4Range, language)}
|
||||
detail={[
|
||||
formatNATRange(nat4Range, language),
|
||||
nat4Networks ? `LXC ${nat4Networks.lxc.subnet} · KVM ${nat4Networks.kvm.subnet}` : '',
|
||||
].filter(Boolean).join(' · ')}
|
||||
action={
|
||||
<button onClick={startEditNAT4} className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-black" title={text.editNAT4Range}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
|
||||
+405
-71
@@ -1,23 +1,42 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
|
||||
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
|
||||
import { Clock, Globe, ListTodo, Lock, LogIn, Minus, Monitor, Plus, RefreshCw, Save, Shield, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
|
||||
import {
|
||||
changePassword,
|
||||
changeUsername,
|
||||
getLoginLogs,
|
||||
getPanelAccessPolicy,
|
||||
getSSLSettings,
|
||||
getTaskQueueSettings,
|
||||
getWebSSHOriginSettings,
|
||||
LoginLog,
|
||||
PanelAccessPolicy,
|
||||
SSLSettings,
|
||||
TaskQueueSettings,
|
||||
updateTaskQueueSettings,
|
||||
updateSSLSettings,
|
||||
updatePanelAccessPolicy,
|
||||
updateWebSSHOriginSettings,
|
||||
WebSSHOriginSettings,
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type SettingsSection = 'tasks' | 'account' | 'access' | 'webssh' | 'ssl' | 'logs'
|
||||
|
||||
const settingsSections = [
|
||||
{ id: 'tasks', label: '任务队列', icon: ListTodo },
|
||||
{ id: 'account', label: '账号设置', icon: UserCog },
|
||||
{ id: 'access', label: '访问来源', icon: Shield },
|
||||
{ id: 'webssh', label: 'WebSSH 访问', icon: Terminal },
|
||||
{ id: 'ssl', label: 'SSL 证书', icon: ShieldCheck },
|
||||
{ id: 'logs', label: '登录日志', icon: LogIn },
|
||||
] as const
|
||||
|
||||
export default function Settings() {
|
||||
const dialog = useDialog()
|
||||
const { username } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [logs, setLogs] = useState<LoginLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
@@ -39,6 +58,15 @@ export default function Settings() {
|
||||
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
|
||||
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
|
||||
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
|
||||
const [taskQueue, setTaskQueue] = useState<TaskQueueSettings | null>(null)
|
||||
const [taskConcurrency, setTaskConcurrency] = useState(2)
|
||||
const [savingTaskQueue, setSavingTaskQueue] = useState(false)
|
||||
const [accessPolicy, setAccessPolicy] = useState<PanelAccessPolicy | null>(null)
|
||||
const [accessEnabled, setAccessEnabled] = useState(false)
|
||||
const [allowedSourcesText, setAllowedSourcesText] = useState('')
|
||||
const [trustedProxiesText, setTrustedProxiesText] = useState('')
|
||||
const [savingAccessPolicy, setSavingAccessPolicy] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('tasks')
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
@@ -78,13 +106,64 @@ export default function Settings() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchTaskQueue = useCallback(async () => {
|
||||
try {
|
||||
const res = await getTaskQueueSettings()
|
||||
const data = res.data.data
|
||||
if (!data) return
|
||||
setTaskQueue(data)
|
||||
setTaskConcurrency(data.concurrency)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchAccessPolicy = useCallback(async () => {
|
||||
try {
|
||||
const res = await getPanelAccessPolicy()
|
||||
const data = res.data.data
|
||||
if (!data) return
|
||||
setAccessPolicy(data)
|
||||
setAccessEnabled(data.enabled)
|
||||
setAllowedSourcesText((data.allowed_sources || []).join('\n'))
|
||||
setTrustedProxiesText((data.trusted_proxies || []).join('\n'))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
fetchSSL()
|
||||
fetchWebSSHOrigins()
|
||||
const timer = setInterval(fetchLogs, 15000)
|
||||
return () => clearInterval(timer)
|
||||
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
|
||||
fetchTaskQueue()
|
||||
fetchAccessPolicy()
|
||||
const logTimer = setInterval(fetchLogs, 15000)
|
||||
const taskTimer = setInterval(fetchTaskQueue, 5000)
|
||||
return () => {
|
||||
clearInterval(logTimer)
|
||||
clearInterval(taskTimer)
|
||||
}
|
||||
}, [fetchAccessPolicy, fetchLogs, fetchSSL, fetchTaskQueue, fetchWebSSHOrigins])
|
||||
|
||||
const handleSaveTaskQueue = async () => {
|
||||
const concurrency = Math.max(1, Math.min(16, Math.round(taskConcurrency || 1)))
|
||||
setSavingTaskQueue(true)
|
||||
try {
|
||||
const res = await updateTaskQueueSettings(concurrency)
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
setTaskQueue(data)
|
||||
setTaskConcurrency(data.concurrency)
|
||||
}
|
||||
dialog.alert('完成', '任务队列并发设置已保存并立即生效')
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('失败', e.response?.data?.message || '任务队列设置保存失败')
|
||||
} finally {
|
||||
setSavingTaskQueue(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
||||
setSSLMode(mode)
|
||||
@@ -139,6 +218,38 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccessEnabledChange = (enabled: boolean) => {
|
||||
setAccessEnabled(enabled)
|
||||
if (enabled && !allowedSourcesText.trim() && accessPolicy?.current_source) {
|
||||
setAllowedSourcesText(accessPolicy.current_source)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAccessPolicy = async () => {
|
||||
const splitEntries = (value: string) => value.split(/[\s,;]+/).map(item => item.trim()).filter(Boolean)
|
||||
setSavingAccessPolicy(true)
|
||||
try {
|
||||
const res = await updatePanelAccessPolicy({
|
||||
enabled: accessEnabled,
|
||||
allowed_sources: splitEntries(allowedSourcesText),
|
||||
trusted_proxies: splitEntries(trustedProxiesText),
|
||||
})
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
setAccessPolicy(data)
|
||||
setAccessEnabled(data.enabled)
|
||||
setAllowedSourcesText((data.allowed_sources || []).join('\n'))
|
||||
setTrustedProxiesText((data.trusted_proxies || []).join('\n'))
|
||||
}
|
||||
dialog.alert('完成', accessEnabled ? '面板访问来源策略已保存并立即生效' : '面板访问来源限制已关闭')
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('失败', e.response?.data?.message || '面板访问来源策略保存失败')
|
||||
} finally {
|
||||
setSavingAccessPolicy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAccount = async () => {
|
||||
if (!oldPwd) {
|
||||
dialog.alert('提示', '请输入当前密码以确认修改')
|
||||
@@ -190,72 +301,291 @@ export default function Settings() {
|
||||
const totalPages = Math.ceil(logs.length / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">账号、安全证书与登录日志</p>
|
||||
<h1 className="text-2xl font-bold text-black dark:text-white">面板设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">任务队列、账号、访问控制、安全证书与访问记录</p>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||
<div className="space-y-6">
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[210px_minmax(0,1fr)]">
|
||||
<aside className="overflow-x-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-900 lg:sticky lg:top-4">
|
||||
<nav className="flex min-w-max gap-1 lg:min-w-0 lg:flex-col" aria-label="设置分类">
|
||||
{settingsSections.map((section) => {
|
||||
const Icon = section.icon
|
||||
const active = activeSection === section.id
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors ${active ? 'bg-black text-white dark:bg-white dark:text-black' : 'text-gray-600 hover:bg-gray-100 hover:text-black dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white'}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t(section.label)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<WebSSHOriginCard
|
||||
settings={webSSHOrigins}
|
||||
originsText={webSSHOriginsText}
|
||||
saving={savingWebSSHOrigins}
|
||||
onOriginsTextChange={setWebSSHOriginsText}
|
||||
onRefresh={fetchWebSSHOrigins}
|
||||
onSave={handleSaveWebSSHOrigins}
|
||||
/>
|
||||
</div>
|
||||
<section className="min-w-0">
|
||||
{activeSection === 'tasks' && (
|
||||
<TaskQueueCard
|
||||
settings={taskQueue}
|
||||
concurrency={taskConcurrency}
|
||||
saving={savingTaskQueue}
|
||||
onConcurrencyChange={setTaskConcurrency}
|
||||
onRefresh={fetchTaskQueue}
|
||||
onSave={handleSaveTaskQueue}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<UserCog className="h-4 w-4" />账号设置
|
||||
{activeSection === 'account' && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<UserCog className="h-4 w-4" />账号设置
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新用户名,留空则不修改</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新密码,留空则不修改</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前密码,验证身份</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={handleSaveAccount} className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200">保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'webssh' && (
|
||||
<WebSSHOriginCard
|
||||
settings={webSSHOrigins}
|
||||
originsText={webSSHOriginsText}
|
||||
saving={savingWebSSHOrigins}
|
||||
onOriginsTextChange={setWebSSHOriginsText}
|
||||
onRefresh={fetchWebSSHOrigins}
|
||||
onSave={handleSaveWebSSHOrigins}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'access' && (
|
||||
<PanelAccessPolicyCard
|
||||
policy={accessPolicy}
|
||||
enabled={accessEnabled}
|
||||
allowedSourcesText={allowedSourcesText}
|
||||
trustedProxiesText={trustedProxiesText}
|
||||
saving={savingAccessPolicy}
|
||||
onEnabledChange={handleAccessEnabledChange}
|
||||
onAllowedSourcesTextChange={setAllowedSourcesText}
|
||||
onTrustedProxiesTextChange={setTrustedProxiesText}
|
||||
onRefresh={fetchAccessPolicy}
|
||||
onSave={handleSaveAccessPolicy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'ssl' && (
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'logs' && (
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskQueueCardProps {
|
||||
settings: TaskQueueSettings | null
|
||||
concurrency: number
|
||||
saving: boolean
|
||||
onConcurrencyChange: (value: number) => void
|
||||
onRefresh: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
interface PanelAccessPolicyCardProps {
|
||||
policy: PanelAccessPolicy | null
|
||||
enabled: boolean
|
||||
allowedSourcesText: string
|
||||
trustedProxiesText: string
|
||||
saving: boolean
|
||||
onEnabledChange: (enabled: boolean) => void
|
||||
onAllowedSourcesTextChange: (value: string) => void
|
||||
onTrustedProxiesTextChange: (value: string) => void
|
||||
onRefresh: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function PanelAccessPolicyCard(props: PanelAccessPolicyCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<Shield className="h-4 w-4" />访问来源策略
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新用户名,留空则不修改</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<label className="mb-1 block text-xs text-gray-500">新密码,留空则不修改</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前密码,验证身份</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800">保存修改</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">限制可访问面板、登录和 API 的来源地址</p>
|
||||
</div>
|
||||
<button type="button" onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800" title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-y border-gray-100 py-3 dark:border-gray-800">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">启用访问白名单</div>
|
||||
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">关闭后不限制访问来源</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={props.enabled}
|
||||
onClick={() => props.onEnabledChange(!props.enabled)}
|
||||
className={`access-policy-switch relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full border transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 dark:focus:ring-white dark:focus:ring-offset-gray-900 ${
|
||||
props.enabled
|
||||
? 'border-black bg-black dark:border-white dark:bg-white'
|
||||
: 'border-gray-300 bg-gray-300 dark:border-gray-600 dark:bg-gray-700'
|
||||
}`}
|
||||
title={props.enabled ? '关闭访问白名单' : '启用访问白名单'}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`access-policy-switch-thumb pointer-events-none absolute left-0.5 top-0.5 h-5 w-5 rounded-full shadow-sm ring-1 ring-black/5 transition-[transform,background-color] duration-200 ${
|
||||
props.enabled
|
||||
? 'translate-x-5 bg-white dark:bg-gray-900'
|
||||
: 'translate-x-0 bg-white dark:bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600 dark:text-gray-300">允许的 IP / CIDR</label>
|
||||
<textarea
|
||||
value={props.allowedSourcesText}
|
||||
onChange={(event) => props.onAllowedSourcesTextChange(event.target.value)}
|
||||
rows={6}
|
||||
disabled={!props.enabled}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black outline-none focus:border-black focus:ring-1 focus:ring-black disabled:bg-gray-50 disabled:text-gray-400 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:focus:border-white dark:focus:ring-white dark:disabled:bg-gray-800 dark:disabled:text-gray-500"
|
||||
placeholder={'203.0.113.10\n192.168.1.0/24\n2001:db8::/32'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600 dark:text-gray-300">可信代理 IP / CIDR</label>
|
||||
<textarea
|
||||
value={props.trustedProxiesText}
|
||||
onChange={(event) => props.onTrustedProxiesTextChange(event.target.value)}
|
||||
rows={6}
|
||||
disabled={!props.enabled}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black outline-none focus:border-black focus:ring-1 focus:ring-black disabled:bg-gray-50 disabled:text-gray-400 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:focus:border-white dark:focus:ring-white dark:disabled:bg-gray-800 dark:disabled:text-gray-500"
|
||||
placeholder={'127.0.0.1\n10.0.0.0/8'}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-gray-500 dark:text-gray-400">仅可信代理可提供真实客户端地址;未使用反向代理时留空</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
<div className="mt-4 grid gap-2 rounded-md border border-gray-100 bg-gray-50 p-3 text-xs dark:border-gray-800 dark:bg-gray-950 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">当前识别来源</span>
|
||||
<div className="mt-0.5 break-all font-mono text-gray-800 dark:text-gray-200">{props.policy?.current_source || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">直接连接来源</span>
|
||||
<div className="mt-0.5 break-all font-mono text-gray-800 dark:text-gray-200">{props.policy?.direct_source || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="button" onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-200">
|
||||
<Save className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存访问策略'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskQueueCard(props: TaskQueueCardProps) {
|
||||
const setBounded = (value: number) => props.onConcurrencyChange(Math.max(1, Math.min(16, value)))
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<ListTodo className="h-4 w-4" />任务队列
|
||||
</h2>
|
||||
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 divide-x divide-gray-200 border-y border-gray-100 bg-gray-50">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[11px] text-gray-500">运行中</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.active ?? 0}</div>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[11px] text-gray-500">等待中</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.pending ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-xs text-gray-500">总并发上限</label>
|
||||
<div className="flex h-9 items-stretch">
|
||||
<button type="button" onClick={() => setBounded(props.concurrency - 1)} disabled={props.concurrency <= 1} className="flex w-10 items-center justify-center rounded-l-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="减少并发">
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
value={props.concurrency}
|
||||
onChange={(event) => setBounded(Number(event.target.value) || 1)}
|
||||
className="min-w-0 flex-1 border-y border-gray-300 px-2 text-center text-sm font-medium text-black outline-none focus:ring-2 focus:ring-inset focus:ring-black"
|
||||
/>
|
||||
<button type="button" onClick={() => setBounded(props.concurrency + 1)} disabled={props.concurrency >= 16} className="flex w-10 items-center justify-center rounded-r-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="增加并发">
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存队列设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -292,7 +622,7 @@ interface WebSSHOriginCardProps {
|
||||
|
||||
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<Terminal className="h-4 w-4" />WebSSH Origin 白名单
|
||||
@@ -307,7 +637,7 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
<textarea
|
||||
value={props.originsText}
|
||||
onChange={(e) => props.onOriginsTextChange(e.target.value)}
|
||||
rows={5}
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
|
||||
/>
|
||||
</div>
|
||||
@@ -315,10 +645,12 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>当前面板来源:{props.settings?.current_origin || '-'}</div>
|
||||
<div className="mt-1">默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。</div>
|
||||
</div>
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -425,10 +757,12 @@ function SSLCard(props: SSLCardProps) {
|
||||
保存后自动重启服务并立即生效
|
||||
</label>
|
||||
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, HardDrive, RefreshCw, Save } from 'lucide-react'
|
||||
import { getStorageInfo, updateStoragePools, StorageDisk, StorageInfo, StoragePool } from '../services/api'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
const contentOptions = [
|
||||
['lxc', 'LXC 容器'],
|
||||
['kvm', 'KVM 磁盘'],
|
||||
['images', '镜像缓存'],
|
||||
['snapshots', '快照'],
|
||||
['backups', '备份'],
|
||||
] as const
|
||||
|
||||
const contentLabels = Object.fromEntries(contentOptions)
|
||||
|
||||
const contentColors: Record<string, string> = {
|
||||
lxc: '#2563eb',
|
||||
kvm: '#7c3aed',
|
||||
images: '#d97706',
|
||||
snapshots: '#059669',
|
||||
backups: '#0891b2',
|
||||
}
|
||||
|
||||
export default function Storage() {
|
||||
const { t } = useLanguage()
|
||||
const [info, setInfo] = useState<StorageInfo | null>(null)
|
||||
const [pools, setPools] = useState<StoragePool[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
const data = res.data.data || { pools: [], disks: [], content_types: [] }
|
||||
setInfo(data)
|
||||
setPools(data.pools || [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveMessage) return
|
||||
const timer = window.setTimeout(() => setSaveMessage(null), 3500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [saveMessage])
|
||||
|
||||
const mountedDisks = useMemo(() => (info?.disks || []).filter((disk) => !!disk.mount_point), [info?.disks])
|
||||
|
||||
const save = async () => {
|
||||
setSaveMessage(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const normalized = pools
|
||||
.map((pool) => ({
|
||||
...pool,
|
||||
id: (pool.id || pool.name || '').trim(),
|
||||
name: (pool.name || '').trim(),
|
||||
path: (pool.path || '').trim(),
|
||||
content_types: pool.content_types || [],
|
||||
default_contents: (pool.default_contents || []).filter((item) => (pool.content_types || []).includes(item)),
|
||||
enabled: pool.enabled !== false,
|
||||
}))
|
||||
const res = await updateStoragePools(normalized)
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
setInfo(data)
|
||||
setPools(data.pools || [])
|
||||
}
|
||||
setSaveMessage({ type: 'success', text: '存储配置已保存' })
|
||||
} catch (err: any) {
|
||||
setSaveMessage({ type: 'error', text: err?.response?.data?.message || '保存存储配置失败' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateDiskPool = (disk: StorageDisk, updater: (pool: StoragePool) => StoragePool) => {
|
||||
setPools((current) => {
|
||||
const index = current.findIndex((pool) => poolForDisk(pool, disk))
|
||||
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
|
||||
const nextPool = updater(base)
|
||||
if (index >= 0) {
|
||||
return current.map((item, i) => i === index ? nextPool : item)
|
||||
}
|
||||
return [...current, nextPool]
|
||||
})
|
||||
}
|
||||
|
||||
const toggleContent = (disk: StorageDisk, content: string) => {
|
||||
updateDiskPool(disk, (pool) => {
|
||||
const current = pool.content_types || []
|
||||
const enabled = current.includes(content)
|
||||
const contentTypes = enabled ? current.filter((item) => item !== content) : [...current, content]
|
||||
return {
|
||||
...pool,
|
||||
enabled: true,
|
||||
content_types: contentTypes,
|
||||
default_contents: (pool.default_contents || []).filter((item) => contentTypes.includes(item)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDefault = (disk: StorageDisk, content: string) => {
|
||||
setPools((current) => {
|
||||
const index = current.findIndex((pool) => poolForDisk(pool, disk))
|
||||
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
|
||||
if (!(base.content_types || []).includes(content)) return current
|
||||
const hasDefault = (base.default_contents || []).includes(content)
|
||||
const baseDefaults = (base.default_contents || []).filter((value) => value !== content)
|
||||
const cleared = current.map((item) => ({
|
||||
...item,
|
||||
default_contents: (item.default_contents || []).filter((value) => value !== content),
|
||||
}))
|
||||
const nextPool = {
|
||||
...base,
|
||||
default_contents: hasDefault ? baseDefaults : [...baseDefaults, content],
|
||||
}
|
||||
if (index >= 0) {
|
||||
return cleared.map((item, i) => i === index ? nextPool : item)
|
||||
}
|
||||
return [...cleared, nextPool]
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black dark:text-white">{t('存储管理')}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t('只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。')}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button onClick={fetchData} className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<RefreshCw className="h-4 w-4" />{t('刷新')}
|
||||
</button>
|
||||
<button onClick={save} disabled={saving} className="inline-flex items-center gap-2 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-4 w-4" />{t(saving ? '保存中...' : '保存')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMessage && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
|
||||
saveMessage.type === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{saveMessage.type === 'success'
|
||||
? <CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
: <AlertCircle className="h-4 w-4 shrink-0" />}
|
||||
<span>{t(saveMessage.text)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white text-sm">
|
||||
<div className="hidden grid-cols-[minmax(170px,0.65fr)_minmax(320px,1.2fr)_minmax(480px,1.8fr)] gap-4 border-b border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-500 2xl:grid">
|
||||
<div className="font-medium">{t('磁盘')}</div>
|
||||
<div className="font-medium">{t('空间分布')}</div>
|
||||
<div className="font-medium">{t('用于存储')}</div>
|
||||
</div>
|
||||
{mountedDisks.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-gray-400">{t('未检测到已挂载磁盘')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{mountedDisks.map((disk) => {
|
||||
const pool = pools.find((item) => poolForDisk(item, disk))
|
||||
const contentUsage = contentUsageMap(pool?.content_usage || disk.content_usage || [])
|
||||
const clicdUsed = pool?.clicd_used_bytes || disk.clicd_used_bytes || 0
|
||||
return (
|
||||
<section
|
||||
key={`${disk.path}-${disk.mount_point}`}
|
||||
className="grid min-w-0 grid-cols-1 gap-4 px-4 py-4 hover:bg-gray-50/70 2xl:grid-cols-[minmax(170px,0.65fr)_minmax(320px,1.2fr)_minmax(480px,1.8fr)]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500 2xl:hidden">{t('磁盘')}</div>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-xs font-medium text-gray-900" title={disk.path || disk.name}>{disk.path || disk.name}</div>
|
||||
<div className="mt-1 truncate text-xs text-gray-500" title={disk.model || disk.fstype || disk.type || '-'}>{disk.model || disk.fstype || disk.type || '-'}</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-gray-400" title={disk.mount_point}>{disk.mount_point}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500 2xl:hidden">{t('空间分布')}</div>
|
||||
<DiskUsageBar disk={disk} contentUsage={contentUsage} clicdUsed={clicdUsed} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500 2xl:hidden">{t('用于存储')}</div>
|
||||
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{contentOptions.map(([value, label]) => {
|
||||
const checked = (pool?.content_types || []).includes(value)
|
||||
const isDefault = (pool?.default_contents || []).includes(value)
|
||||
return (
|
||||
<div key={value} className={`min-w-0 rounded-md border px-2.5 py-2 ${checked ? 'border-gray-300 bg-white' : 'border-gray-200 bg-gray-50'}`}>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-700">
|
||||
<input className="shrink-0" type="checkbox" checked={checked} onChange={() => toggleContent(disk, value)} />
|
||||
<span className="truncate" title={t(label)}>{t(label)}</span>
|
||||
</label>
|
||||
{checked && (
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2 border-t border-gray-100 pt-1.5">
|
||||
<span className="truncate text-[11px] text-gray-500">{t('默认盘')}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isDefault}
|
||||
title={isDefault ? `${t('关闭')} ${t(label)} ${t('默认盘')}` : `${t('设为')} ${t(label)} ${t('默认盘')}`}
|
||||
onClick={() => toggleDefault(disk, value)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 appearance-none items-center rounded-full border p-0 transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 ${isDefault ? 'border-black bg-black' : 'border-gray-300 bg-gray-200'}`}
|
||||
>
|
||||
<span className={`pointer-events-none absolute left-0.5 top-0.5 block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${isDefault ? 'translate-x-4' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiskUsageBar({
|
||||
disk,
|
||||
contentUsage,
|
||||
clicdUsed,
|
||||
}: {
|
||||
disk: StorageDisk
|
||||
contentUsage: Record<string, number>
|
||||
clicdUsed: number
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const total = Math.max(0, disk.size_bytes || 0)
|
||||
const free = Math.max(0, Math.min(total, disk.free_bytes || 0))
|
||||
const used = Math.max(0, total - free)
|
||||
const rawContentSegments = contentOptions.map(([value, label]) => ({
|
||||
key: value,
|
||||
label,
|
||||
size: Math.max(0, contentUsage[value] || 0),
|
||||
color: contentColors[value],
|
||||
}))
|
||||
const rawContentTotal = rawContentSegments.reduce((sum, segment) => sum + segment.size, 0)
|
||||
const normalizedClicdUsed = Math.max(0, Math.min(used, Math.max(clicdUsed || 0, rawContentTotal)))
|
||||
const contentScale = rawContentTotal > normalizedClicdUsed && rawContentTotal > 0
|
||||
? normalizedClicdUsed / rawContentTotal
|
||||
: 1
|
||||
const contentSegments = rawContentSegments.map((segment) => ({ ...segment, size: segment.size * contentScale }))
|
||||
const categorizedClicdUsed = contentSegments.reduce((sum, segment) => sum + segment.size, 0)
|
||||
const unclassifiedClicdUsed = Math.max(0, normalizedClicdUsed - categorizedClicdUsed)
|
||||
const nonClicdUsed = Math.max(0, used - normalizedClicdUsed)
|
||||
const segments = [
|
||||
...contentSegments,
|
||||
{ key: 'clicd-other', label: 'CLICD 其他', size: unclassifiedClicdUsed, color: '#111827' },
|
||||
{ key: 'other', label: '非 CLICD', size: nonClicdUsed, color: '#4b5563' },
|
||||
{ key: 'free', label: '可用空间', size: free, color: '#e5e7eb' },
|
||||
].filter((segment) => segment.size > 0)
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
<span>{t('已用')} {formatBytes(used)} / {formatBytes(total)}</span>
|
||||
<span>{usagePct(used, total).toFixed(1)}% · {t('可用')} {formatBytes(free)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex h-8 w-full overflow-hidden rounded-md border border-gray-300 bg-gray-100">
|
||||
{segments.map((segment) => {
|
||||
const pct = usagePct(segment.size, total)
|
||||
return (
|
||||
<div
|
||||
key={segment.key}
|
||||
title={`${t(segment.label)}: ${formatBytes(segment.size)} (${pct.toFixed(2)}%)`}
|
||||
className="flex h-full items-center justify-center overflow-hidden border-r border-white/70 text-[10px] font-medium text-white last:border-r-0"
|
||||
style={{ width: `${pct}%`, minWidth: pct > 0 && pct < 0.6 ? '3px' : undefined, backgroundColor: segment.color }}
|
||||
>
|
||||
{pct >= 9 && <span className={segment.key === 'free' ? 'text-gray-600' : ''}>{t(segment.label)}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{segments.map((segment) => (
|
||||
<div key={segment.key} className="flex items-center gap-1.5 text-[11px] text-gray-600">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-sm border border-black/5" style={{ backgroundColor: segment.color }} />
|
||||
<span>{t(segment.label)}</span>
|
||||
<span className="font-medium text-gray-800">{formatBytes(segment.size)}</span>
|
||||
<span className="text-gray-400">{usagePct(segment.size, total).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function poolForDisk(pool: StoragePool, disk: StorageDisk) {
|
||||
if (!disk.mount_point) return false
|
||||
const mount = cleanPath(disk.mount_point)
|
||||
const poolMount = cleanPath(pool.mount_point || '')
|
||||
const poolPath = cleanPath(pool.path || '')
|
||||
return poolMount === mount || poolPath === mount || poolPath.startsWith(`${mount}/`)
|
||||
}
|
||||
|
||||
function defaultPoolForDisk(disk: StorageDisk): StoragePool {
|
||||
const mount = cleanPath(disk.mount_point || '/')
|
||||
const baseName = mount === '/' ? 'system' : mount.split('/').filter(Boolean).pop() || disk.name || 'disk'
|
||||
const primaryContents = mount === '/' ? contentOptions.map(([value]) => value) : []
|
||||
return {
|
||||
id: `disk-${slugID(mount === '/' ? 'root' : baseName)}`,
|
||||
name: `${baseName} (${disk.path || disk.name})`,
|
||||
path: mount === '/' ? '/var/lib/clicd' : `${mount}/clicd`,
|
||||
content_types: primaryContents,
|
||||
default_contents: [...primaryContents],
|
||||
enabled: true,
|
||||
mount_point: disk.mount_point,
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPath(value: string) {
|
||||
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || '/'
|
||||
}
|
||||
|
||||
function slugID(value: string) {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'storage'
|
||||
}
|
||||
|
||||
function contentUsageMap(items: Array<{ content_type: string; size_bytes: number }>) {
|
||||
return items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.content_type] = (acc[item.content_type] || 0) + (item.size_bytes || 0)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function usagePct(used: number, total: number) {
|
||||
if (!total || total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, (used / total) * 100))
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!bytes) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index++
|
||||
}
|
||||
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
@@ -31,6 +32,7 @@ interface AuditLogExt extends AuditLog {
|
||||
|
||||
export default function SubUserManagement() {
|
||||
const dialog = useDialog()
|
||||
const { t } = useLanguage()
|
||||
const [users, setUsers] = useState<SubUserItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
|
||||
@@ -173,8 +175,10 @@ export default function SubUserManagement() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-black dark:text-white">子用户管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">容器分配的子用户列表,共 {users.length} 个</p>
|
||||
<h1 className="text-xl font-semibold text-black dark:text-white">{t('子用户管理')}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('容器分配的子用户列表,共')} {users.length} {t('个')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
|
||||
@@ -21,10 +21,15 @@ api.interceptors.request.use((config) => {
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const requestURL = String(error.config?.url || '')
|
||||
const isLoginRequest = ['/login', '/sub-user/login', '/sub-user/access']
|
||||
.some((path) => requestURL === path || requestURL.endsWith(path))
|
||||
if (error.response?.status === 401 && !isLoginRequest) {
|
||||
localStorage.removeItem('clicd_token')
|
||||
localStorage.removeItem('clicd_username')
|
||||
window.location.href = '/login'
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
@@ -75,6 +80,8 @@ export interface Container {
|
||||
uuid: string
|
||||
name: string
|
||||
virtualization?: string
|
||||
storage_pool_id?: string
|
||||
storage_path?: string
|
||||
template: string
|
||||
vcpu: number
|
||||
ram_mb: number
|
||||
@@ -143,6 +150,7 @@ export interface CreateContainerRequest {
|
||||
name: string
|
||||
virtualization: string
|
||||
template_id: string
|
||||
storage_pool_id?: string
|
||||
vcpu: number
|
||||
cpu_percent: number
|
||||
ram_mb: number
|
||||
@@ -158,6 +166,8 @@ export interface CreateContainerRequest {
|
||||
io_read_mbps: number
|
||||
io_write_mbps: number
|
||||
extra_ports: number[]
|
||||
nat_port_mappings?: PortMapping[]
|
||||
management_port?: number
|
||||
port_mapping_count: number
|
||||
assign_nat?: boolean
|
||||
lan_ipv4_mode?: string
|
||||
@@ -180,6 +190,51 @@ export interface CreateContainerRequest {
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface StoragePool {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
content_types: string[]
|
||||
default_contents?: string[]
|
||||
enabled: boolean
|
||||
available?: boolean
|
||||
exists?: boolean
|
||||
size_bytes?: number
|
||||
used_bytes?: number
|
||||
free_bytes?: number
|
||||
mount_point?: string
|
||||
clicd_used_bytes?: number
|
||||
content_usage?: StorageContentUsage[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface StorageContentUsage {
|
||||
content_type: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
export interface StorageDisk {
|
||||
name: string
|
||||
path: string
|
||||
type: string
|
||||
fstype: string
|
||||
mount_point: string
|
||||
model: string
|
||||
size_bytes: number
|
||||
used_bytes: number
|
||||
free_bytes: number
|
||||
storage_pool_id?: string
|
||||
storage_path?: string
|
||||
clicd_used_bytes?: number
|
||||
content_usage?: StorageContentUsage[]
|
||||
}
|
||||
|
||||
export interface StorageInfo {
|
||||
pools: StoragePool[]
|
||||
disks: StorageDisk[]
|
||||
content_types: string[]
|
||||
}
|
||||
|
||||
export interface ReinstallContainerOptions {
|
||||
ssh_auth_mode?: string
|
||||
ssh_password?: string
|
||||
@@ -258,6 +313,10 @@ export interface HostInfo {
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateSnapshotOptions {
|
||||
storage_pool_id?: string
|
||||
}
|
||||
|
||||
export interface HostMetricPoint {
|
||||
ts: number
|
||||
cpu: number
|
||||
@@ -430,6 +489,18 @@ export interface AuditLog {
|
||||
export const getLoginLogs = () =>
|
||||
api.get<APIResponse<LoginLog[]>>('/login-logs')
|
||||
|
||||
export interface TaskQueueSettings {
|
||||
concurrency: number
|
||||
active: number
|
||||
pending: number
|
||||
}
|
||||
|
||||
export const getTaskQueueSettings = () =>
|
||||
api.get<APIResponse<TaskQueueSettings>>('/task-queue/settings')
|
||||
|
||||
export const updateTaskQueueSettings = (concurrency: number) =>
|
||||
api.put<APIResponse<TaskQueueSettings>>('/task-queue/settings', { concurrency })
|
||||
|
||||
export interface SSLCertificateInfo {
|
||||
subject: string
|
||||
issuer: string
|
||||
@@ -482,6 +553,21 @@ export const getWebSSHOriginSettings = () =>
|
||||
export const updateWebSSHOriginSettings = (origins: string[]) =>
|
||||
api.put<APIResponse<WebSSHOriginSettings>>('/webssh-origins', { origins })
|
||||
|
||||
export interface PanelAccessPolicy {
|
||||
enabled: boolean
|
||||
allowed_sources: string[]
|
||||
trusted_proxies: string[]
|
||||
current_source: string
|
||||
direct_source: string
|
||||
using_forwarded: boolean
|
||||
}
|
||||
|
||||
export const getPanelAccessPolicy = () =>
|
||||
api.get<APIResponse<PanelAccessPolicy>>('/access-policy')
|
||||
|
||||
export const updatePanelAccessPolicy = (data: Pick<PanelAccessPolicy, 'enabled' | 'allowed_sources' | 'trusted_proxies'>) =>
|
||||
api.put<APIResponse<PanelAccessPolicy>>('/access-policy', data)
|
||||
|
||||
// Containers
|
||||
export const getContainers = () =>
|
||||
api.get<APIResponse<Container[]>>('/containers')
|
||||
@@ -578,6 +664,18 @@ export const getIPv6Status = () =>
|
||||
export const assignIPv6 = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
|
||||
|
||||
export interface IPAssignmentUpdateRequest {
|
||||
mode: 'clear' | 'random' | 'custom'
|
||||
count?: number
|
||||
addresses?: string[]
|
||||
}
|
||||
|
||||
export const updatePublicIPv4Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
|
||||
api.put<APIResponse<Container>>(`/containers/${id}/public-ipv4`, data)
|
||||
|
||||
export const updateIPv6Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
|
||||
api.put<APIResponse<Container>>(`/containers/${id}/ipv6-addresses`, data)
|
||||
|
||||
export interface RouteCapacity {
|
||||
used: number
|
||||
remaining: string
|
||||
@@ -639,6 +737,11 @@ export interface IPv6Route {
|
||||
export interface RoutingInfo {
|
||||
nat4: RouteCapacity
|
||||
nat4_port_range: NAT4PortRange
|
||||
nat4_next_port: number
|
||||
nat4_networks: {
|
||||
lxc: NATNetworkInfo
|
||||
kvm: NATNetworkInfo
|
||||
}
|
||||
ipv4: RouteCapacity
|
||||
lan_dhcp: RouteCapacity
|
||||
ipv6: RouteCapacity
|
||||
@@ -696,11 +799,37 @@ export interface ImageInfo {
|
||||
size_bytes: number
|
||||
manual_path?: string
|
||||
desktop?: string
|
||||
provisioner?: string
|
||||
custom?: boolean
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
export interface CustomKVMImageInput {
|
||||
type: 'lxc' | 'kvm'
|
||||
name: string
|
||||
description: string
|
||||
distro: string
|
||||
release: string
|
||||
arch: string
|
||||
url: string
|
||||
provisioner?: 'linux-cloud-init' | 'windows-10' | 'windows-11' | 'lxc-rootfs'
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
export interface CustomKVMImage extends CustomKVMImageInput {
|
||||
id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const getImages = () =>
|
||||
api.get<APIResponse<ImageInfo[]>>('/images')
|
||||
|
||||
export const createCustomKVMImage = (payload: CustomKVMImageInput) =>
|
||||
api.post<APIResponse<CustomKVMImage>>('/images/custom', payload)
|
||||
|
||||
export const removeCustomKVMImage = (id: string) =>
|
||||
api.delete<APIResponse>('/images/custom', { data: { id } })
|
||||
|
||||
export const downloadImage = (templateId: string) =>
|
||||
api.post<APIResponse>('/images/download', { template_id: templateId })
|
||||
|
||||
@@ -729,6 +858,12 @@ export const getHostHistory = () =>
|
||||
export const getHostReport = () =>
|
||||
api.get<APIResponse<HostProbeReport>>('/host-report')
|
||||
|
||||
export const getStorageInfo = () =>
|
||||
api.get<APIResponse<StorageInfo>>('/storage')
|
||||
|
||||
export const updateStoragePools = (pools: StoragePool[]) =>
|
||||
api.put<APIResponse<StorageInfo>>('/storage', { pools })
|
||||
|
||||
// Snapshots
|
||||
export interface Snapshot {
|
||||
id: string
|
||||
@@ -763,8 +898,8 @@ export const getSnapshots = () =>
|
||||
export const getContainerSnapshots = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
|
||||
|
||||
export const createContainerSnapshot = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
|
||||
export const createContainerSnapshot = (id: ContainerIdentifier, options?: CreateSnapshotOptions) =>
|
||||
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, options || {}, { timeout: 600000 })
|
||||
|
||||
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
|
||||
@@ -806,6 +941,8 @@ export interface Task {
|
||||
container_name: string
|
||||
status: string
|
||||
error?: string
|
||||
stage?: string
|
||||
stage_detail?: string
|
||||
created_at: string
|
||||
template_id?: string
|
||||
config?: CreateContainerRequest
|
||||
@@ -837,6 +974,16 @@ export interface SubUser {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface NATNetworkInfo {
|
||||
subnet: string
|
||||
gateway: string
|
||||
netmask: string
|
||||
dhcp_start: string
|
||||
dhcp_end: string
|
||||
dhcp_max: number
|
||||
prefix_bits: number
|
||||
}
|
||||
|
||||
export const createSubUser = (containerId: ContainerIdentifier) =>
|
||||
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ const exact: Record<string, string> = {
|
||||
'输入密码': 'Enter password',
|
||||
'登录': 'Log in',
|
||||
'登录中...': 'Logging in...',
|
||||
'用户名或密码错误': 'Incorrect username or password',
|
||||
'访问码或密码错误': 'Incorrect access code or password',
|
||||
'登录失败,请检查用户名和密码': 'Login failed. Check your username and password.',
|
||||
'Authentication required': 'Authentication required',
|
||||
'Administrator permission required': 'Administrator permission required',
|
||||
@@ -193,6 +195,33 @@ const exact: Record<string, string> = {
|
||||
'请按红色提示修改 vCPU、内存或磁盘配置': 'Fix the vCPU, memory, or disk fields marked in red',
|
||||
'创建失败': 'Create failed',
|
||||
'创建新容器': 'Create New Container',
|
||||
'创建步骤': 'Creation steps',
|
||||
'基础信息': 'Basics',
|
||||
'镜像选择': 'Image',
|
||||
'网络配置': 'Network',
|
||||
'预览清单': 'Review',
|
||||
'基础信息有误': 'Invalid basic information',
|
||||
'请填写有效且未被占用的容器名称': 'Enter a valid, available container name',
|
||||
'KVM 磁盘': 'KVM Disk',
|
||||
'请选择镜像': 'Select an image',
|
||||
'请选择用于创建容器的系统镜像': 'Select the system image used to create the container',
|
||||
'网络配置有误': 'Invalid network configuration',
|
||||
'请至少启用一种网络连接方式': 'Enable at least one network connection mode',
|
||||
'NAT 端口配置有误': 'Invalid NAT port configuration',
|
||||
'自动分配': 'Auto assign',
|
||||
'个端口': 'ports',
|
||||
'局域网': 'LAN',
|
||||
'未配置网络': 'No network configured',
|
||||
'创建数量': 'Count',
|
||||
'自动选择': 'Automatic',
|
||||
'镜像与登录': 'Image and Login',
|
||||
'镜像默认': 'Image default',
|
||||
'自动生成密码': 'Auto-generated password',
|
||||
'子用户可用镜像': 'Sub-user Images',
|
||||
'主要网络': 'Primary Network',
|
||||
'上一步': 'Back',
|
||||
'下一步': 'Next',
|
||||
'确认创建': 'Create',
|
||||
'批量创建数量': 'Batch Count',
|
||||
'虚拟化架构': 'Virtualization',
|
||||
'LXC 容器': 'LXC Container',
|
||||
@@ -392,6 +421,41 @@ const exact: Record<string, string> = {
|
||||
'暂未获取到宿主机信息': 'No host information available',
|
||||
'面板资源状态与容器概览': 'Panel resource status and container overview',
|
||||
'宿主机资源状态与容器概览': 'Host resource status and container overview',
|
||||
'存储管理': 'Storage Management',
|
||||
'只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。': 'Only mounted disks are shown. Enable a content type to make that disk available to the corresponding feature.',
|
||||
'空间分布': 'Space Distribution',
|
||||
'用于存储': 'Storage Usage',
|
||||
'未检测到已挂载磁盘': 'No mounted disks detected',
|
||||
'镜像缓存': 'Image Cache',
|
||||
'备份': 'Backups',
|
||||
'默认盘': 'Default Disk',
|
||||
'设为': 'Set as',
|
||||
'CLICD 其他': 'Other CLICD Data',
|
||||
'非 CLICD': 'Non-CLICD Data',
|
||||
'可用空间': 'Free Space',
|
||||
'存储配置已保存': 'Storage settings saved',
|
||||
'保存存储配置失败': 'Failed to save storage settings',
|
||||
'任务队列、账号、安全证书与访问记录': 'Task queue, account, certificates, and access records',
|
||||
'任务队列、账号、访问控制、安全证书与访问记录': 'Task queue, account, access control, certificates, and access records',
|
||||
'设置分类': 'Settings categories',
|
||||
'访问来源': 'Access Sources',
|
||||
'访问来源策略': 'Access Source Policy',
|
||||
'限制可访问面板、登录和 API 的来源地址': 'Restrict source addresses that can access the panel, login, and APIs',
|
||||
'启用访问白名单': 'Enable Access Allowlist',
|
||||
'关闭后不限制访问来源': 'No source restrictions when disabled',
|
||||
'关闭访问白名单': 'Disable Access Allowlist',
|
||||
'允许的 IP / CIDR': 'Allowed IP / CIDR',
|
||||
'可信代理 IP / CIDR': 'Trusted Proxy IP / CIDR',
|
||||
'仅可信代理可提供真实客户端地址;未使用反向代理时留空': 'Only trusted proxies may supply the real client address. Leave empty without a reverse proxy.',
|
||||
'当前识别来源': 'Detected Source',
|
||||
'直接连接来源': 'Direct Connection Source',
|
||||
'保存访问策略': 'Save Access Policy',
|
||||
'面板访问来源策略已保存并立即生效': 'Panel access source policy saved and applied immediately',
|
||||
'面板访问来源限制已关闭': 'Panel access source restriction disabled',
|
||||
'面板访问来源策略保存失败': 'Failed to save panel access source policy',
|
||||
'面板访问来源策略': 'Panel Access Source Policy',
|
||||
'更新面板访问来源策略': 'Update Panel Access Source Policy',
|
||||
'WebSSH 访问': 'WebSSH Access',
|
||||
'账号设置': 'Account Settings',
|
||||
'当前用户名': 'Current Username',
|
||||
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
|
||||
@@ -401,6 +465,12 @@ const exact: Record<string, string> = {
|
||||
'至少 6 位': 'At least 6 characters',
|
||||
'输入当前密码以确认修改': 'Enter current password to confirm changes',
|
||||
'保存修改': 'Save Changes',
|
||||
'总并发上限': 'Total Concurrency Limit',
|
||||
'减少并发': 'Decrease concurrency',
|
||||
'增加并发': 'Increase concurrency',
|
||||
'保存队列设置': 'Save Queue Settings',
|
||||
'任务队列并发设置已保存并立即生效': 'Task queue concurrency saved and applied immediately',
|
||||
'任务队列设置保存失败': 'Failed to save task queue settings',
|
||||
'SSL 证书': 'SSL Certificate',
|
||||
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
|
||||
'IP / 域名': 'IP / Domain',
|
||||
@@ -645,6 +715,32 @@ const exact: Record<string, string> = {
|
||||
'快照配额': 'Snapshot Quota',
|
||||
'模板列表': 'Template List',
|
||||
'镜像管理列表': 'Image Management List',
|
||||
'第三方镜像': 'Third-party Image',
|
||||
'下载第三方 KVM 镜像': 'Download Third-party KVM Image',
|
||||
'下载第三方 LXC 镜像': 'Download Third-party LXC Image',
|
||||
'支持 tar、tar.gz、tar.xz、tar.zst 格式的 Linux rootfs': 'Supports Linux rootfs archives in tar, tar.gz, tar.xz, and tar.zst formats',
|
||||
'移除第三方镜像': 'Remove Third-party Image',
|
||||
'第三方镜像已移除': 'Third-party image removed',
|
||||
'移除第三方镜像失败': 'Failed to remove third-party image',
|
||||
'第三方镜像已添加,下载任务已启动': 'Third-party image added and download started',
|
||||
'镜像格式必须与所选无人值守安装模板匹配': 'The image format must match the selected unattended installation template',
|
||||
'无人值守安装模板': 'Unattended Installation Template',
|
||||
'镜像名称': 'Image Name',
|
||||
'版本 / 代号': 'Version / Codename',
|
||||
'镜像下载地址': 'Image Download URL',
|
||||
'镜像来源、版本或用途': 'Image source, version, or purpose',
|
||||
'用于校验下载文件完整性': 'Used to verify download integrity',
|
||||
'添加并下载': 'Add and Download',
|
||||
'正在添加...': 'Adding...',
|
||||
'添加第三方镜像失败': 'Failed to add third-party image',
|
||||
'添加第三方 KVM 镜像源': 'Add Third-party KVM Image Source',
|
||||
'移除第三方 KVM 镜像源': 'Remove Third-party KVM Image Source',
|
||||
'添加第三方 LXC/KVM 镜像源': 'Add Third-party LXC/KVM Image Source',
|
||||
'移除第三方 LXC/KVM 镜像源': 'Remove Third-party LXC/KVM Image Source',
|
||||
'请填写名称、发行版、版本和下载地址': 'Enter the name, distribution, version, and download URL',
|
||||
'SHA-256 必须是 64 位十六进制字符串': 'SHA-256 must be a 64-character hexadecimal string',
|
||||
'安装 ISO': 'Installation ISO',
|
||||
'校验中': 'Validating',
|
||||
'取消镜像下载': 'Cancel Image Download',
|
||||
'启用/禁用镜像': 'Enable / Disable Image',
|
||||
'安全连接日志': 'Security Connection Logs',
|
||||
@@ -761,6 +857,32 @@ const exact: Record<string, string> = {
|
||||
'初始化失败': 'Initialization failed',
|
||||
'初始化完成': 'Initialization complete',
|
||||
'排队等待': 'Queued',
|
||||
'当前阶段': 'Current Stage',
|
||||
'准备初始化环境': 'Preparing initialization environment',
|
||||
'检查模板与创建参数': 'Checking template and creation settings',
|
||||
'下载模板并创建基础文件系统': 'Downloading template and creating root filesystem',
|
||||
'复制容器数据到存储磁盘': 'Copying container data to storage disk',
|
||||
'创建容量限制磁盘并复制 rootfs': 'Creating quota disk and copying rootfs',
|
||||
'配置 CPU、内存与网络限制': 'Configuring CPU, memory, and network limits',
|
||||
'分配 IPv4、IPv6 与 NAT 端口': 'Allocating IPv4, IPv6, and NAT ports',
|
||||
'保存容器配置': 'Saving container configuration',
|
||||
'写入容器网络配置': 'Writing container network configuration',
|
||||
'安装并配置 SSH 服务': 'Installing and configuring SSH',
|
||||
'检测并预配置 SSH 服务': 'Detecting and preconfiguring SSH',
|
||||
'转换非特权容器文件权限': 'Converting unprivileged container permissions',
|
||||
'设置容器登录凭据': 'Setting container login credentials',
|
||||
'启动容器并等待网络就绪': 'Starting container and waiting for network',
|
||||
'启动虚拟机并等待网络就绪': 'Starting VM and waiting for network',
|
||||
'检查 KVM 镜像与创建参数': 'Checking KVM image and creation settings',
|
||||
'选择虚拟机存储磁盘': 'Selecting VM storage disk',
|
||||
'分配 IPv4 与 IPv6 地址': 'Allocating IPv4 and IPv6 addresses',
|
||||
'创建 Windows 虚拟磁盘': 'Creating Windows virtual disk',
|
||||
'生成 Windows 自动应答配置': 'Generating Windows unattended setup',
|
||||
'创建 KVM 系统磁盘': 'Creating KVM system disk',
|
||||
'生成 cloud-init 初始化配置': 'Generating cloud-init configuration',
|
||||
'注册 KVM 虚拟机': 'Registering KVM virtual machine',
|
||||
'分配并配置 NAT 端口': 'Allocating and configuring NAT ports',
|
||||
'保存虚拟机配置': 'Saving virtual machine configuration',
|
||||
'处理中': 'Processing',
|
||||
'未知系统': 'Unknown system',
|
||||
'处理失败': 'Failed',
|
||||
@@ -886,6 +1008,132 @@ const exact: Record<string, string> = {
|
||||
'生成新密码': 'Generate new password',
|
||||
'自定义密码': 'Custom password',
|
||||
'生成密码': 'Generate password',
|
||||
'不限速': 'Unlimited',
|
||||
'下': 'Down',
|
||||
'不限': 'Unlimited',
|
||||
'/ 上': '/ Up',
|
||||
'请选择登录方式': 'Select a login method',
|
||||
'未检测到可分配公网 IPv4': 'No allocatable public IPv4 detected',
|
||||
'使用': 'Use',
|
||||
'正在检测 IPv6 前缀...': 'Checking IPv6 prefixes...',
|
||||
'公网 NAT': 'Public NAT',
|
||||
'不分配 NAT 端口': 'Do not assign NAT ports',
|
||||
'未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。': 'No allocatable IPv6 prefix was detected. The host only has a single /128 IPv6 address, which cannot be assigned to containers.',
|
||||
'宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。': 'The host detected an IPv6 prefix, but the outbound IPv6 connectivity test failed.',
|
||||
'个可分配地址': 'allocatable addresses',
|
||||
'将分配': 'Will assign',
|
||||
'请勾选任意一个可用网络': 'Select at least one available network',
|
||||
'局域网 IPv4 配置有误': 'Invalid LAN IPv4 configuration',
|
||||
'请填写有效的 IPv4 地址、子网掩码和网关': 'Enter a valid IPv4 address, subnet mask, and gateway',
|
||||
'未配置存储': 'Storage not configured',
|
||||
'请先在存储管理中为': 'In Storage Management, enable storage for',
|
||||
'开启至少一块存储磁盘': 'Enable at least one storage disk',
|
||||
'登录方式有误': 'Invalid login method',
|
||||
'至': 'to',
|
||||
'当前宿主机不支持 KVM': 'The current host does not support KVM',
|
||||
'系统镜像,请先在「镜像管理」中下载镜像模板。': 'system images available. Download an image template from Images first.',
|
||||
'存储磁盘': 'Storage Disk',
|
||||
'自动选择(默认盘优先,空间不足自动切换)': 'Automatic selection (prefer default disk and switch when space is insufficient)',
|
||||
'尚未开启': 'Not enabled',
|
||||
'存储,当前无法创建。': 'storage is not enabled, so creation is currently unavailable.',
|
||||
'去开启': 'Configure Now',
|
||||
'默认勾选当前系统;取消后,子用户也不能重装该系统。': 'The current system is selected by default. Clearing it also prevents sub-users from reinstalling that system.',
|
||||
'局域网 DHCP': 'LAN DHCP',
|
||||
'macvlan 独立局域网 IP': 'Independent LAN IP via macvlan',
|
||||
'未检测到可用上联网卡': 'No available uplink interface detected',
|
||||
'DHCP 自动获取': 'Obtain automatically via DHCP',
|
||||
'子网掩码': 'Subnet Mask',
|
||||
'不选则长期有效': 'Leave blank for no expiration',
|
||||
'均': 'Avg',
|
||||
'/ 峰': '/ Peak',
|
||||
'到期': 'Expires',
|
||||
'未分配': 'Unassigned',
|
||||
'下行': 'Download',
|
||||
'上行': 'Upload',
|
||||
'修改公网 IP 分配': 'Change Public IP Assignment',
|
||||
'尚未开启快照存储,无法新建或启用定时快照。': 'Snapshot storage is not enabled. New and scheduled snapshots are unavailable.',
|
||||
'新建快照存储磁盘': 'Storage Disk for New Snapshots',
|
||||
'仅影响手动新建快照;定时快照使用默认磁盘。': 'Only affects manually created snapshots. Scheduled snapshots use the default disk.',
|
||||
'在': 'at',
|
||||
'IPv4 规则覆盖': 'IPv4 rules cover',
|
||||
'独立公网 IPv4': 'independent public IPv4',
|
||||
'公网 IP 分配': 'Public IP Assignment',
|
||||
'修改后会重放端口映射、SNAT 和防火墙规则。': 'Changing assignments reapplies port mappings, SNAT, and firewall rules.',
|
||||
'随机数量': 'Random Count',
|
||||
'没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。': 'No public IPv4 addresses are available. Configure the IPv4 pool in Routing first.',
|
||||
'独立 IPv6': 'Independent IPv6',
|
||||
'自定义地址必须落在路由管理配置的 IPv6 前缀内。': 'Custom addresses must be within an IPv6 prefix configured in Routing.',
|
||||
'未分配 IPv4 NAT 端口配额': 'No IPv4 NAT port quota assigned',
|
||||
'已达到管理员分配的 IPv4 NAT 端口配额': 'The administrator-assigned IPv4 NAT port quota has been reached',
|
||||
'不分配': 'Do Not Assign',
|
||||
'随机分配': 'Random Allocation',
|
||||
'自定义': 'Custom',
|
||||
'SSH Key 格式不正确': 'Invalid SSH key format',
|
||||
'公网 IP 分配失败': 'Public IP assignment failed',
|
||||
'请检查地址是否可用或已被占用': 'Check whether the address is available or already in use',
|
||||
'未分配 IPv4 NAT': 'IPv4 NAT not assigned',
|
||||
'该容器未分配 IPv4 NAT 端口配额。': 'This container has no IPv4 NAT port quota.',
|
||||
'未配置快照存储': 'Snapshot storage not configured',
|
||||
'请先在存储管理中为快照开启至少一块存储磁盘。': 'Enable at least one snapshot storage disk in Storage Management first.',
|
||||
'个月': 'months',
|
||||
'个任务': 'tasks',
|
||||
'剩余': 'Remaining',
|
||||
'磨损': 'Wear',
|
||||
'启停': 'Power Cycles',
|
||||
'线程': 'threads',
|
||||
'块硬盘': 'disks',
|
||||
'个进程': 'processes',
|
||||
'虚拟': 'Virtual',
|
||||
'尚未开启镜像缓存存储,无法下载新镜像。': 'Image cache storage is not enabled, so new images cannot be downloaded.',
|
||||
'请先在存储管理中开启镜像缓存存储': 'Enable image cache storage in Storage Management first',
|
||||
'正在检查存储配置...': 'Checking storage configuration...',
|
||||
'池内': 'In Pool',
|
||||
'范围': 'Range',
|
||||
'条映射': 'mappings',
|
||||
'模式': 'Mode',
|
||||
'NAT4、公网 IPv4 池和 IPv6 地址分配': 'NAT4, public IPv4 pool, and IPv6 address assignment',
|
||||
'编辑 NAT4 范围': 'Edit NAT4 Range',
|
||||
'起始端口': 'Start Port',
|
||||
'结束端口': 'End Port',
|
||||
'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口': 'The NAT4 range must be within 1-65535, and the start port cannot exceed the end port',
|
||||
'保存 NAT4 范围失败': 'Failed to save NAT4 range',
|
||||
'剩余 / 总数': 'Remaining / Total',
|
||||
'由局域网 DHCP 分配': 'Assigned by LAN DHCP',
|
||||
'局域网 DHCP 分配': 'LAN DHCP Assignments',
|
||||
'暂无局域网 DHCP 分配': 'No LAN DHCP assignments',
|
||||
'公网 IPv4 池': 'Public IPv4 Pool',
|
||||
'编辑 IP 池': 'Edit IP Pool',
|
||||
'暂未配置公网 IPv4 池': 'No public IPv4 pool configured',
|
||||
'掩码': 'Mask',
|
||||
'分配给': 'Assigned To',
|
||||
'空闲': 'Free',
|
||||
'编辑 IPv4 池': 'Edit IPv4 Pool',
|
||||
'IPv4 网关不能为空': 'IPv4 gateway is required',
|
||||
'IPv4 地址不能为空': 'IPv4 address is required',
|
||||
'保存 IPv4 池失败': 'Failed to save IPv4 pool',
|
||||
'打开容器': 'Open Container',
|
||||
'IPv4 池内暂无地址': 'No addresses in the IPv4 pool',
|
||||
'添加 IPv4': 'Add IPv4',
|
||||
'检测到的 IPv6 前缀': 'Detected IPv6 Prefixes',
|
||||
'暂无 IPv6 前缀': 'No IPv6 prefixes',
|
||||
'IPv6 网卡不能为空': 'IPv6 interface is required',
|
||||
'本机': 'Local',
|
||||
'暂无 IPv4 NAT 映射': 'No IPv4 NAT mappings',
|
||||
'运行时名称': 'Runtime Name',
|
||||
'客户机 IPv4': 'Guest IPv4',
|
||||
'宿主 IPv4': 'Host IPv4',
|
||||
'宿主端口': 'Host Port',
|
||||
'客户机端口': 'Guest Port',
|
||||
'大量': 'Large',
|
||||
'的快照吗?此操作不可恢复。': ' snapshot? This action cannot be undone.',
|
||||
'· 默认勾选当前系统,取消后将禁止重装该系统': ' · the current system is selected by default; clearing it prevents reinstalling that system',
|
||||
'暂无已下载并启用的镜像': 'No downloaded and enabled images',
|
||||
'已选择': 'Selected',
|
||||
'加载失败': 'Loading failed',
|
||||
'请填写 SSH 公钥': 'Enter an SSH public key',
|
||||
'SSH 公钥长度不能超过 8192 字符': 'The SSH public key cannot exceed 8192 characters',
|
||||
'SSH 公钥只能填写一行': 'The SSH public key must be on one line',
|
||||
'SSH 公钥格式不正确': 'Invalid SSH public key format',
|
||||
}
|
||||
|
||||
const artifactPatterns: RegExp[] = [
|
||||
@@ -939,6 +1187,7 @@ const replacements: Array<[RegExp, string]> = [
|
||||
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
||||
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*个\s*任务/g, 'Total $1 tasks'],
|
||||
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
|
||||
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
||||
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
||||
@@ -988,6 +1237,7 @@ const replacements: Array<[RegExp, string]> = [
|
||||
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
|
||||
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
|
||||
[/阶段:(.+)$/g, 'Stage: $1'],
|
||||
[/正在初始化:(.+)$/g, 'Initializing: $1'],
|
||||
[/\$\{days\}天/g, '${days} days'],
|
||||
[/\$\{hours\}小时/g, '${hours} hours'],
|
||||
[/\$\{hours\}\s*小时/g, '${hours} hours'],
|
||||
|
||||
+426
-7
@@ -9,6 +9,7 @@ ISSUE_URL="https://github.com/${REPO}/issues"
|
||||
LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
|
||||
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
|
||||
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
|
||||
CLICD_NETWORK_ENV="/etc/clicd/network.env"
|
||||
|
||||
normalize_clicd_arch() {
|
||||
arch="$1"
|
||||
@@ -207,6 +208,7 @@ tr_msg() {
|
||||
-e 's/存储环境检查/Storage environment check/g' \
|
||||
-e 's/安装系统依赖/Install system dependencies/g' \
|
||||
-e 's/配置内核网络参数/Configure kernel networking/g' \
|
||||
-e 's/配置 LXC NAT 网络/Configure LXC NAT network/g' \
|
||||
-e 's/配置运行时服务/Configure runtime services/g' \
|
||||
-e 's/配置 libvirt default NAT 网络/Configure libvirt default NAT network/g' \
|
||||
-e 's/配置 UID\/GID 映射/Configure UID\/GID mapping/g' \
|
||||
@@ -417,6 +419,8 @@ Environment variables:
|
||||
CLICD_REPO=owner/repo Default: ${REPO}
|
||||
CLICD_VERSION=latest|v1.0.0 Default: latest
|
||||
CLICD_LANG=en|zh Default: auto
|
||||
CLICD_LXC_SUBNET=10.0.3.0/24 Default: auto-detect an available private subnet
|
||||
CLICD_KVM_SUBNET=192.168.122.0/24
|
||||
CLICD_LOG_FILE=/path/file.log Default: ${LOG_FILE}
|
||||
|
||||
Examples:
|
||||
@@ -438,6 +442,8 @@ EOF
|
||||
CLICD_REPO=owner/repo 默认:${REPO}
|
||||
CLICD_VERSION=latest|v1.0.0 默认:latest
|
||||
CLICD_LANG=en|zh 默认:自动检测
|
||||
CLICD_LXC_SUBNET=10.0.3.0/24 默认:自动检测可用私网网段
|
||||
CLICD_KVM_SUBNET=192.168.122.0/24
|
||||
CLICD_LOG_FILE=/path/file.log 默认:${LOG_FILE}
|
||||
|
||||
示例:
|
||||
@@ -844,8 +850,15 @@ delete_ip6tables_bridge_rules() {
|
||||
cleanup_clicd_networking() {
|
||||
log "正在清理 CLICD 防火墙和网桥规则..."
|
||||
delete_iptables_lines nat PREROUTING 'clicd-'
|
||||
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
|
||||
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
|
||||
delete_iptables_lines nat POSTROUTING 'clicd-'
|
||||
configured_lxc_subnet="$(sed -n 's/^CLICD_LXC_SUBNET=//p' "$CLICD_NETWORK_ENV" 2>/dev/null | tail -n 1)"
|
||||
configured_kvm_subnet="$(sed -n 's/^CLICD_KVM_SUBNET=//p' "$CLICD_NETWORK_ENV" 2>/dev/null | tail -n 1)"
|
||||
[ -n "$configured_lxc_subnet" ] || configured_lxc_subnet="$(ip -4 route show dev lxcbr0 proto kernel scope link 2>/dev/null | awk '$1 ~ /\// {print $1; exit}' || true)"
|
||||
[ -n "$configured_kvm_subnet" ] || configured_kvm_subnet="$(ip -4 route show dev virbr0 proto kernel scope link 2>/dev/null | awk '$1 ~ /\// {print $1; exit}' || true)"
|
||||
for subnet in 10.0.3.0/24 192.168.122.0/24 "$configured_lxc_subnet" "$configured_kvm_subnet"; do
|
||||
[ -n "$subnet" ] || continue
|
||||
delete_iptables_rule nat POSTROUTING -s "$subnet" -o eth+ -j MASQUERADE
|
||||
done
|
||||
cleanup_clicd_ipv6_from_config
|
||||
cleanup_clicd_ipv6_bridge_routes
|
||||
|
||||
@@ -857,6 +870,21 @@ cleanup_clicd_networking() {
|
||||
delete_ip6tables_bridge_rules
|
||||
}
|
||||
|
||||
restore_lxc_network_configs() {
|
||||
for path in /etc/default/lxc-net /etc/sysconfig/lxc-net /etc/conf.d/lxc-net /etc/conf.d/lxc-bridge; do
|
||||
backup="${path}.clicd-backup"
|
||||
if [ -f "$backup" ]; then
|
||||
mv -f "$backup" "$path"
|
||||
log "已恢复 $path"
|
||||
elif [ -f "${path}.clicd-created" ]; then
|
||||
remove_path "$path"
|
||||
fi
|
||||
rm -f "${path}.clicd-created"
|
||||
done
|
||||
remove_path "$CLICD_NETWORK_ENV"
|
||||
rmdir /etc/clicd >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
remove_clicd_host_hooks() {
|
||||
if has_cmd systemctl; then
|
||||
systemctl stop clicd-kvm-ipv6.service >/dev/null 2>&1 || true
|
||||
@@ -954,6 +982,7 @@ uninstall_clicd() {
|
||||
destroy_clicd_kvm_domains
|
||||
remove_clicd_libvirt_default_network
|
||||
cleanup_clicd_networking
|
||||
restore_lxc_network_configs
|
||||
remove_clicd_host_hooks
|
||||
remove_clicd_quota_records
|
||||
|
||||
@@ -1019,6 +1048,7 @@ install_apk() {
|
||||
tar \
|
||||
gzip \
|
||||
xz \
|
||||
python3 \
|
||||
lxc \
|
||||
lxc-download \
|
||||
lxc-openrc \
|
||||
@@ -1058,6 +1088,7 @@ install_apt() {
|
||||
tar \
|
||||
gzip \
|
||||
xz-utils \
|
||||
python3 \
|
||||
lxc \
|
||||
lxc-templates \
|
||||
lxcfs \
|
||||
@@ -1127,6 +1158,7 @@ install_dnf() {
|
||||
tar \
|
||||
gzip \
|
||||
xz \
|
||||
python3 \
|
||||
lxc \
|
||||
lxc-templates \
|
||||
bridge-utils \
|
||||
@@ -1169,6 +1201,7 @@ install_yum() {
|
||||
tar \
|
||||
gzip \
|
||||
xz \
|
||||
python3 \
|
||||
lxc \
|
||||
lxc-templates \
|
||||
bridge-utils \
|
||||
@@ -1252,6 +1285,328 @@ install_dependencies() {
|
||||
fi
|
||||
}
|
||||
|
||||
network_prompt_available() {
|
||||
[ -r /dev/tty ] && [ -w /dev/tty ] && { printf '' > /dev/tty; } 2>/dev/null
|
||||
}
|
||||
|
||||
current_bridge_subnet() {
|
||||
bridge="$1"
|
||||
subnet="$(ip -4 route show dev "$bridge" proto kernel scope link 2>/dev/null | awk '$1 ~ /\// {print $1; exit}' || true)"
|
||||
if [ -z "$subnet" ]; then
|
||||
subnet="$(ip -4 route show dev "$bridge" 2>/dev/null | awk '$1 ~ /\// {print $1; exit}' || true)"
|
||||
fi
|
||||
printf '%s' "$subnet"
|
||||
}
|
||||
|
||||
saved_nat_subnet() {
|
||||
key="$1"
|
||||
bridge="$2"
|
||||
saved=""
|
||||
if [ -f "$CLICD_NETWORK_ENV" ]; then
|
||||
saved="$(sed -n "s/^${key}=//p" "$CLICD_NETWORK_ENV" 2>/dev/null | tail -n 1)"
|
||||
fi
|
||||
if [ -z "$saved" ]; then
|
||||
saved="$(current_bridge_subnet "$bridge")"
|
||||
fi
|
||||
if [ -z "$saved" ] && [ "$key" = "CLICD_LXC_SUBNET" ]; then
|
||||
for path in /etc/default/lxc-net /etc/sysconfig/lxc-net /etc/conf.d/lxc-net /etc/conf.d/lxc-bridge; do
|
||||
[ -f "$path" ] || continue
|
||||
saved="$(sed -n 's/^[[:space:]]*LXC_NETWORK=["'\'']*\([^"'\'']*\)["'\'']*[[:space:]]*$/\1/p' "$path" | tail -n 1)"
|
||||
[ -n "$saved" ] && break
|
||||
done
|
||||
fi
|
||||
printf '%s' "$saved"
|
||||
}
|
||||
|
||||
resolve_nat_network() {
|
||||
role="$1"
|
||||
requested="$2"
|
||||
hint="$3"
|
||||
exclude_bridge="$4"
|
||||
extra_blocked="$5"
|
||||
CLICD_NET_ROLE="$role" \
|
||||
CLICD_NET_REQUESTED="$requested" \
|
||||
CLICD_NET_HINT="$hint" \
|
||||
CLICD_NET_EXCLUDE_BRIDGE="$exclude_bridge" \
|
||||
CLICD_NET_EXTRA_BLOCKED="$extra_blocked" \
|
||||
python3 - <<'PY'
|
||||
import ipaddress
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
def clean_excepthook(exc_type, value, traceback):
|
||||
if issubclass(exc_type, ValueError):
|
||||
print(value, file=sys.stderr)
|
||||
return
|
||||
sys.__excepthook__(exc_type, value, traceback)
|
||||
|
||||
sys.excepthook = clean_excepthook
|
||||
|
||||
role = os.environ.get("CLICD_NET_ROLE", "lxc")
|
||||
requested = os.environ.get("CLICD_NET_REQUESTED", "").strip()
|
||||
hint = os.environ.get("CLICD_NET_HINT", "").strip()
|
||||
exclude_bridge = os.environ.get("CLICD_NET_EXCLUDE_BRIDGE", "").strip()
|
||||
extra_blocked = os.environ.get("CLICD_NET_EXTRA_BLOCKED", "").strip()
|
||||
private_ranges = tuple(
|
||||
ipaddress.ip_network(item)
|
||||
for item in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
|
||||
)
|
||||
|
||||
def parse_private(value):
|
||||
try:
|
||||
network = ipaddress.ip_network(value, strict=False)
|
||||
except ValueError as exc:
|
||||
raise ValueError("请输入有效的 IPv4 CIDR,例如 172.28.40.0/24") from exc
|
||||
if network.version != 4:
|
||||
raise ValueError("NAT 网段必须是 IPv4 CIDR")
|
||||
if network.prefixlen < 16 or network.prefixlen > 28:
|
||||
raise ValueError("NAT 网段前缀长度必须在 /16 到 /28 之间")
|
||||
if not any(network.subnet_of(private) for private in private_ranges):
|
||||
raise ValueError("NAT 网段必须使用 RFC1918 私网地址")
|
||||
return network
|
||||
|
||||
def run(*args):
|
||||
try:
|
||||
return subprocess.run(args, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
blocked = []
|
||||
route_types = {"broadcast", "local", "unreachable", "blackhole", "throw", "prohibit"}
|
||||
for line in run("ip", "-4", "route", "show", "table", "all").splitlines():
|
||||
fields = line.split()
|
||||
if not fields:
|
||||
continue
|
||||
index = 1 if fields[0] in route_types else 0
|
||||
if index >= len(fields) or fields[index] == "default":
|
||||
continue
|
||||
if "dev" in fields:
|
||||
dev_index = fields.index("dev")
|
||||
if dev_index + 1 < len(fields) and fields[dev_index + 1] == exclude_bridge:
|
||||
continue
|
||||
try:
|
||||
blocked.append(ipaddress.ip_network(fields[index], strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
for name in run("virsh", "net-list", "--all", "--name").splitlines():
|
||||
name = name.strip()
|
||||
if not name or (role == "kvm" and name == "default"):
|
||||
continue
|
||||
xml = run("virsh", "net-dumpxml", name)
|
||||
if not xml:
|
||||
continue
|
||||
try:
|
||||
root = ET.fromstring(xml)
|
||||
except ET.ParseError:
|
||||
continue
|
||||
for item in root.findall("ip"):
|
||||
address = item.get("address", "")
|
||||
netmask = item.get("netmask", "")
|
||||
prefix = item.get("prefix", "")
|
||||
if not address:
|
||||
continue
|
||||
try:
|
||||
blocked.append(ipaddress.ip_network(f"{address}/{prefix or netmask}", strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if extra_blocked:
|
||||
try:
|
||||
blocked.append(ipaddress.ip_network(extra_blocked, strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def conflicts(network):
|
||||
return [item for item in blocked if network.overlaps(item)]
|
||||
|
||||
selected = None
|
||||
if requested and requested.lower() != "auto":
|
||||
selected = parse_private(requested)
|
||||
overlaps = conflicts(selected)
|
||||
if overlaps:
|
||||
joined = ", ".join(str(item) for item in overlaps[:5])
|
||||
raise ValueError(f"网段 {selected} 与宿主机现有网络冲突:{joined}")
|
||||
else:
|
||||
if hint:
|
||||
try:
|
||||
candidate = parse_private(hint)
|
||||
if not conflicts(candidate):
|
||||
selected = candidate
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
defaults = ["10.0.3.0/24"] if role == "lxc" else ["192.168.122.0/24"]
|
||||
base_octet = 240 if role == "lxc" else 241
|
||||
ten_candidates = [
|
||||
f"10.{base_octet + offset // 256}.{offset % 256}.0/24"
|
||||
for offset in range(0, 1024)
|
||||
if base_octet + offset // 256 <= 250
|
||||
]
|
||||
seventeen_candidates = [
|
||||
f"172.{second}.{third}.0/24"
|
||||
for second in range(31, 15, -1)
|
||||
for third in range(0, 256)
|
||||
]
|
||||
one_ninety_two_candidates = [
|
||||
f"192.168.{third}.0/24"
|
||||
for third in range(240, -1, -1)
|
||||
]
|
||||
candidates = defaults + ten_candidates + seventeen_candidates + one_ninety_two_candidates
|
||||
if selected is None:
|
||||
for raw in candidates:
|
||||
candidate = ipaddress.ip_network(raw)
|
||||
if not conflicts(candidate):
|
||||
selected = candidate
|
||||
break
|
||||
|
||||
if selected is None:
|
||||
raise ValueError("没有找到可用的私网网段,请通过 CLICD_LXC_SUBNET/CLICD_KVM_SUBNET 手动指定")
|
||||
|
||||
hosts = selected.num_addresses
|
||||
gateway = selected.network_address + 1
|
||||
dhcp_start = selected.network_address + 2
|
||||
dhcp_end = selected.broadcast_address - 1
|
||||
dhcp_max = hosts - 3
|
||||
print("|".join((
|
||||
str(selected),
|
||||
str(gateway),
|
||||
str(selected.netmask),
|
||||
str(dhcp_start),
|
||||
str(dhcp_end),
|
||||
str(dhcp_max),
|
||||
)))
|
||||
PY
|
||||
}
|
||||
|
||||
prompt_nat_network() {
|
||||
role="$1"
|
||||
label_zh="$2"
|
||||
label_en="$3"
|
||||
env_value="$4"
|
||||
hint="$5"
|
||||
bridge="$6"
|
||||
extra_blocked="$7"
|
||||
requested="$env_value"
|
||||
|
||||
while :; do
|
||||
if [ -z "$requested" ] && network_prompt_available; then
|
||||
if [ "$CLICD_LANG_DETECTED" = "en" ]; then
|
||||
printf " %s (IPv4 CIDR, press Enter to auto-detect): " "$label_en" > /dev/tty
|
||||
else
|
||||
printf " %s(IPv4 CIDR,回车自动检测可用网段): " "$label_zh" > /dev/tty
|
||||
fi
|
||||
IFS= read -r requested < /dev/tty || requested=""
|
||||
fi
|
||||
[ -n "$requested" ] || requested="auto"
|
||||
|
||||
error_file="/tmp/clicd-network-error.$$"
|
||||
if values="$(resolve_nat_network "$role" "$requested" "$hint" "$bridge" "$extra_blocked" 2>"$error_file")"; then
|
||||
rm -f "$error_file"
|
||||
printf '%s' "$values"
|
||||
return
|
||||
fi
|
||||
error_message="$(cat "$error_file" 2>/dev/null || true)"
|
||||
rm -f "$error_file"
|
||||
if ! network_prompt_available || [ -n "$env_value" ]; then
|
||||
die "${error_message:-NAT 网段配置无效。}"
|
||||
fi
|
||||
warn "${error_message:-NAT 网段配置无效,请重新输入。}"
|
||||
requested=""
|
||||
done
|
||||
}
|
||||
|
||||
choose_nat_networks() {
|
||||
lxc_hint="$(saved_nat_subnet CLICD_LXC_SUBNET lxcbr0)"
|
||||
kvm_hint="$(saved_nat_subnet CLICD_KVM_SUBNET virbr0)"
|
||||
|
||||
lxc_values="$(prompt_nat_network lxc "LXC NAT 网段" "LXC NAT subnet" "${CLICD_LXC_SUBNET:-}" "$lxc_hint" lxcbr0 "")"
|
||||
old_ifs="$IFS"
|
||||
IFS='|'
|
||||
set -- $lxc_values
|
||||
IFS="$old_ifs"
|
||||
LXC_NAT_SUBNET="$1"
|
||||
LXC_NAT_GATEWAY="$2"
|
||||
LXC_NAT_NETMASK="$3"
|
||||
LXC_NAT_DHCP_START="$4"
|
||||
LXC_NAT_DHCP_END="$5"
|
||||
LXC_NAT_DHCP_MAX="$6"
|
||||
|
||||
kvm_values="$(prompt_nat_network kvm "KVM NAT 网段" "KVM NAT subnet" "${CLICD_KVM_SUBNET:-}" "$kvm_hint" virbr0 "$LXC_NAT_SUBNET")"
|
||||
IFS='|'
|
||||
set -- $kvm_values
|
||||
IFS="$old_ifs"
|
||||
KVM_NAT_SUBNET="$1"
|
||||
KVM_NAT_GATEWAY="$2"
|
||||
KVM_NAT_NETMASK="$3"
|
||||
KVM_NAT_DHCP_START="$4"
|
||||
KVM_NAT_DHCP_END="$5"
|
||||
KVM_NAT_DHCP_MAX="$6"
|
||||
|
||||
export CLICD_LXC_SUBNET="$LXC_NAT_SUBNET"
|
||||
export CLICD_KVM_SUBNET="$KVM_NAT_SUBNET"
|
||||
log "NAT 网络:LXC=${LXC_NAT_SUBNET} gateway=${LXC_NAT_GATEWAY},KVM=${KVM_NAT_SUBNET} gateway=${KVM_NAT_GATEWAY}"
|
||||
}
|
||||
|
||||
write_lxc_network_config() {
|
||||
path="$1"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
if [ -f "${path}.clicd-created" ]; then
|
||||
:
|
||||
elif [ -f "$path" ] && [ ! -f "${path}.clicd-backup" ]; then
|
||||
cp -p "$path" "${path}.clicd-backup"
|
||||
elif [ ! -f "$path" ]; then
|
||||
touch "${path}.clicd-created"
|
||||
fi
|
||||
cat > "$path" << EOF
|
||||
USE_LXC_BRIDGE="true"
|
||||
LXC_BRIDGE="lxcbr0"
|
||||
LXC_ADDR="${LXC_NAT_GATEWAY}"
|
||||
LXC_NETMASK="${LXC_NAT_NETMASK}"
|
||||
LXC_NETWORK="${LXC_NAT_SUBNET}"
|
||||
LXC_DHCP_RANGE="${LXC_NAT_DHCP_START},${LXC_NAT_DHCP_END}"
|
||||
LXC_DHCP_MAX="${LXC_NAT_DHCP_MAX}"
|
||||
LXC_DHCP_CONFILE=""
|
||||
LXC_DOMAIN=""
|
||||
EOF
|
||||
}
|
||||
|
||||
configure_lxc_nat_network() {
|
||||
previous="$(current_bridge_subnet lxcbr0)"
|
||||
if [ -n "$previous" ] && [ "$previous" != "$LXC_NAT_SUBNET" ]; then
|
||||
active="$(lxc-ls --active 2>/dev/null | tr '\n' ' ' | sed 's/[[:space:]]*$//' || true)"
|
||||
if [ -n "$active" ] && [ "${CLICD_FORCE_NAT_RECONFIGURE:-0}" != "1" ]; then
|
||||
die "LXC NAT 网段将从 ${previous} 修改为 ${LXC_NAT_SUBNET},但仍有运行中的 LXC:${active}。请先关机,或设置 CLICD_FORCE_NAT_RECONFIGURE=1。"
|
||||
fi
|
||||
if is_systemd; then
|
||||
systemctl stop lxc-net.service >/dev/null 2>&1 || true
|
||||
elif is_openrc; then
|
||||
rc-service lxc-net stop >/dev/null 2>&1 || rc-service lxc-bridge stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
ip link delete lxcbr0 >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
write_lxc_network_config /etc/default/lxc-net
|
||||
case "$OS_ID" in
|
||||
alpine)
|
||||
write_lxc_network_config /etc/conf.d/lxc-net
|
||||
write_lxc_network_config /etc/conf.d/lxc-bridge
|
||||
;;
|
||||
centos|rhel|rocky|almalinux|fedora)
|
||||
write_lxc_network_config /etc/sysconfig/lxc-net
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$(dirname "$CLICD_NETWORK_ENV")"
|
||||
cat > "$CLICD_NETWORK_ENV" << EOF
|
||||
CLICD_LXC_SUBNET=${LXC_NAT_SUBNET}
|
||||
CLICD_KVM_SUBNET=${KVM_NAT_SUBNET}
|
||||
EOF
|
||||
chmod 0644 "$CLICD_NETWORK_ENV"
|
||||
}
|
||||
|
||||
configure_kernel_networking() {
|
||||
log "正在启用内核转发配置..."
|
||||
cat > /etc/sysctl.d/99-clicd.conf << 'EOF'
|
||||
@@ -1308,6 +1663,13 @@ setup_runtime_services() {
|
||||
if is_openrc; then
|
||||
rc-update add cgroups default >/dev/null 2>&1 || true
|
||||
rc-service cgroups start >/dev/null 2>&1 || true
|
||||
if rc-service -e lxc-net >/dev/null 2>&1; then
|
||||
rc-update add lxc-net default >/dev/null 2>&1 || true
|
||||
rc-service lxc-net restart >/dev/null 2>&1 || true
|
||||
elif rc-service -e lxc-bridge >/dev/null 2>&1; then
|
||||
rc-update add lxc-bridge default >/dev/null 2>&1 || true
|
||||
rc-service lxc-bridge restart >/dev/null 2>&1 || true
|
||||
fi
|
||||
rc-update add lxc default >/dev/null 2>&1 || true
|
||||
rc-service lxc start >/dev/null 2>&1 || true
|
||||
rc-update add lxcfs default >/dev/null 2>&1 || true
|
||||
@@ -1326,7 +1688,36 @@ setup_runtime_services() {
|
||||
|
||||
|
||||
libvirt_network_active() {
|
||||
virsh net-info default 2>/dev/null | awk -F: 'tolower($1) ~ /^[[:space:]]*active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' | grep -qx yes
|
||||
LC_ALL=C LANG=C virsh net-info default 2>/dev/null \
|
||||
| awk -F: '$1 ~ /^[[:space:]]*Active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' \
|
||||
| grep -qx yes
|
||||
}
|
||||
|
||||
libvirt_default_subnet() {
|
||||
LC_ALL=C LANG=C virsh net-dumpxml default 2>/dev/null |
|
||||
python3 -c '
|
||||
import ipaddress
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
try:
|
||||
root = ET.parse(sys.stdin).getroot()
|
||||
item = root.find("ip")
|
||||
address = item.get("address", "")
|
||||
mask = item.get("prefix", "") or item.get("netmask", "")
|
||||
print(ipaddress.ip_network(f"{address}/{mask}", strict=False))
|
||||
except Exception:
|
||||
pass
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
libvirt_default_in_use() {
|
||||
LC_ALL=C LANG=C virsh list --all --name 2>/dev/null | while IFS= read -r domain; do
|
||||
[ -n "$domain" ] || continue
|
||||
if LC_ALL=C LANG=C virsh domiflist "$domain" 2>/dev/null |
|
||||
awk '($2 == "network" && $3 == "default") || ($2 == "bridge" && $3 == "virbr0") {found=1} END {exit !found}'; then
|
||||
printf '%s\n' "$domain"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
setup_default_libvirt_network() {
|
||||
@@ -1335,16 +1726,27 @@ setup_default_libvirt_network() {
|
||||
return
|
||||
fi
|
||||
log "正在检查 libvirt default NAT 网络..."
|
||||
current_subnet="$(libvirt_default_subnet)"
|
||||
if [ -n "$current_subnet" ] && [ "$current_subnet" != "$KVM_NAT_SUBNET" ]; then
|
||||
domains="$(libvirt_default_in_use | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
|
||||
if [ -n "$domains" ] && [ "${CLICD_FORCE_NAT_RECONFIGURE:-0}" != "1" ]; then
|
||||
die "KVM NAT 网段将从 ${current_subnet} 修改为 ${KVM_NAT_SUBNET},但 libvirt default 网络仍被虚拟机使用:${domains}。请先关机,或设置 CLICD_FORCE_NAT_RECONFIGURE=1。"
|
||||
fi
|
||||
if libvirt_network_active; then
|
||||
LC_ALL=C LANG=C virsh net-destroy default >/dev/null
|
||||
fi
|
||||
LC_ALL=C LANG=C virsh net-undefine default >/dev/null
|
||||
fi
|
||||
if ! virsh net-info default >/dev/null 2>&1; then
|
||||
net_xml="$(mktemp /tmp/clicd-default-net.XXXXXX.xml)"
|
||||
cat > "$net_xml" << 'EOF'
|
||||
cat > "$net_xml" << EOF
|
||||
<network>
|
||||
<name>default</name>
|
||||
<bridge name='virbr0'/>
|
||||
<forward mode='nat'/>
|
||||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||||
<ip address='${KVM_NAT_GATEWAY}' netmask='${KVM_NAT_NETMASK}'>
|
||||
<dhcp>
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
<range start='${KVM_NAT_DHCP_START}' end='${KVM_NAT_DHCP_END}'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>
|
||||
@@ -1355,7 +1757,13 @@ EOF
|
||||
touch "$LIBVIRT_DEFAULT_MARKER"
|
||||
fi
|
||||
if ! libvirt_network_active; then
|
||||
virsh net-start default
|
||||
if ! start_output="$(LC_ALL=C LANG=C virsh net-start default 2>&1)"; then
|
||||
# Another process may have activated the network after our check.
|
||||
if ! libvirt_network_active; then
|
||||
printf '%s\n' "$start_output" >&2
|
||||
die "libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
virsh net-autostart default >/dev/null
|
||||
if ! libvirt_network_active; then
|
||||
@@ -1597,6 +2005,7 @@ Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=1048576
|
||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
EnvironmentFile=-${CLICD_NETWORK_ENV}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1620,6 +2029,12 @@ pidfile="/run/clicd.pid"
|
||||
output_log="/var/log/clicd.log"
|
||||
error_log="/var/log/clicd.err"
|
||||
|
||||
if [ -r /etc/clicd/network.env ]; then
|
||||
set -a
|
||||
. /etc/clicd/network.env
|
||||
set +a
|
||||
fi
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after lxc libvirtd
|
||||
@@ -1700,6 +2115,8 @@ print_summary() {
|
||||
echo "====================================="
|
||||
echo " $(tr_msg "Web 面板:")http://YOUR_SERVER_IP:8999"
|
||||
echo " $(tr_msg "二进制:")/usr/local/bin/clicd"
|
||||
echo " LXC NAT: ${LXC_NAT_SUBNET} (gateway ${LXC_NAT_GATEWAY})"
|
||||
echo " KVM NAT: ${KVM_NAT_SUBNET} (gateway ${KVM_NAT_GATEWAY})"
|
||||
echo " $(tr_msg "安装日志:")$LOG_FILE"
|
||||
echo " $(tr_msg "问题反馈:")$ISSUE_URL"
|
||||
if is_systemd; then
|
||||
@@ -1725,7 +2142,9 @@ print_summary() {
|
||||
run_step "兼容性检查" check_os_compatibility
|
||||
run_step "存储环境检查" check_storage_compatibility
|
||||
run_step "安装系统依赖" install_dependencies
|
||||
choose_nat_networks
|
||||
run_step "配置内核网络参数" configure_kernel_networking
|
||||
run_step "配置 LXC NAT 网络" configure_lxc_nat_network
|
||||
run_step "配置运行时服务" setup_runtime_services
|
||||
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
|
||||
run_step "配置 UID/GID 映射" setup_subids
|
||||
|
||||
Reference in New Issue
Block a user