release: v1.1.22

This commit is contained in:
MengMengCode
2026-07-16 16:52:15 +08:00
parent 596bf86477
commit 61137b837d
31 changed files with 996 additions and 171 deletions
+38 -10
View File
@@ -14,9 +14,15 @@ permissions:
contents: write contents: write
jobs: jobs:
linux-amd64: linux:
name: Linux amd64 name: Linux ${{ matrix.goarch }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goarch:
- amd64
- arm64
steps: steps:
- name: Checkout - name: Checkout
@@ -55,16 +61,21 @@ jobs:
fi fi
- name: Build CLICD - name: Build CLICD
env:
CLICD_GOARCH: ${{ matrix.goarch }}
run: bash build.sh run: bash build.sh
- name: Package CLICD - name: Package CLICD
env:
CLICD_GOARCH: ${{ matrix.goarch }}
run: | run: |
mkdir -p dist package/clicd-linux-amd64 asset_dir="clicd-linux-${CLICD_GOARCH}"
cp build/clicd package/clicd-linux-amd64/clicd mkdir -p "dist" "package/${asset_dir}"
cp build/install.sh package/clicd-linux-amd64/install.sh cp build/clicd "package/${asset_dir}/clicd"
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh cp build/install.sh "package/${asset_dir}/install.sh"
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64 chmod +x "package/${asset_dir}/clicd" "package/${asset_dir}/install.sh"
cp build/clicd dist/clicd-linux-amd64 tar -C package -czf "dist/${asset_dir}.tar.gz" "${asset_dir}"
cp build/clicd "dist/${asset_dir}"
- name: Package Mofang module - name: Package Mofang module
run: | run: |
@@ -87,11 +98,28 @@ jobs:
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: clicd-linux-amd64 name: clicd-linux-${{ matrix.goarch }}
path: dist/* path: dist/*
release:
name: Publish GitHub Release
needs: linux
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist-artifacts
- name: Prepare release assets
run: |
mkdir -p dist
find dist-artifacts -maxdepth 2 -type f ! -name SHA256SUMS -print -exec cp -f {} dist/ \;
sha256sum dist/* > dist/SHA256SUMS
- name: Publish GitHub Release - name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
+2
View File
@@ -13,6 +13,7 @@ backend/internal/server/web/*
# Build artifacts # Build artifacts
/build/ /build/
/dist/
Mofang/*.zip Mofang/*.zip
*.exe *.exe
*.dll *.dll
@@ -69,3 +70,4 @@ push-release.ps1
deploy.ps1 deploy.ps1
backend/clicd backend/clicd
api.md api.md
deploy-arm.ps1
+355 -17
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -219,6 +220,9 @@ var hostCPUMu sync.Mutex
var lastHostCPU cpuTimes var lastHostCPU cpuTimes
var hostIOMu sync.Mutex var hostIOMu sync.Mutex
var lastHostIO hostIOSample var lastHostIO hostIOSample
var egressIPv4Mu sync.Mutex
var cachedEgressIPv4 lxc.PublicIPInfo
var cachedEgressIPv4At time.Time
type cpuTimes struct { type cpuTimes struct {
Total uint64 Total uint64
@@ -397,7 +401,7 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
now := unixNano() now := unixNano()
network := NetworkInfo{RXBytes: rx, TXBytes: tx} network := NetworkInfo{RXBytes: rx, TXBytes: tx}
publicIPv4 := lxc.DetectPublicIPv4() publicIPv4 := detectDisplayPublicIPv4()
network.PublicIPv4 = publicIPv4.Address network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface network.PublicIPv4Interface = publicIPv4.Interface
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0) network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
@@ -437,23 +441,79 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
} }
func readHostNetworkBytes() (uint64, uint64) { func readHostNetworkBytes() (uint64, uint64) {
entries, err := os.ReadDir("/sys/class/net") ifaces := detectHostTrafficInterfaces()
if err != nil { if len(ifaces) == 0 {
return 0, 0 ifaces = fallbackHostTrafficInterfaces()
} }
var rx, tx uint64 var rx, tx uint64
for _, entry := range entries { for _, name := range ifaces {
name := entry.Name()
if name == "lo" {
continue
}
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes") rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes") tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
} }
return rx, tx return rx, tx
} }
func detectHostTrafficInterfaces() []string {
seen := map[string]bool{}
result := make([]string, 0, 2)
add := func(name string) {
name = strings.TrimSpace(name)
if !isHostTrafficInterface(name) || seen[name] {
return
}
seen[name] = true
result = append(result, name)
}
if iface, _ := detectDefaultIPv4Route(); iface != "" {
add(iface)
}
if iface, _ := detectDefaultIPv6Route(); iface != "" {
add(iface)
}
if pub := lxc.DetectPublicIPv4(); pub.Interface != "" {
add(pub.Interface)
}
for _, prefix := range lxc.DetectHostPublicIPv6Prefixes() {
add(prefix.Interface)
}
return result
}
func fallbackHostTrafficInterfaces() []string {
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return nil
}
result := make([]string, 0)
for _, entry := range entries {
name := entry.Name()
if !isHostTrafficInterface(name) {
continue
}
state := strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "operstate")))
if state == "down" {
continue
}
result = append(result, name)
}
sort.Strings(result)
return result
}
func isHostTrafficInterface(name string) bool {
name = strings.TrimSpace(name)
if name == "" || name == "lo" {
return false
}
if isContainerLikeInterfaceName(name) {
return false
}
return true
}
func readHostDiskBytes() (uint64, uint64) { func readHostDiskBytes() (uint64, uint64) {
f, err := os.Open("/proc/diskstats") f, err := os.Open("/proc/diskstats")
if err != nil { if err != nil {
@@ -574,6 +634,8 @@ func trimOSReleaseValue(value string) string {
func detectHostCPUProbe() HostCPUProbe { func detectHostCPUProbe() HostCPUProbe {
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH} probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
armImplementer := ""
armPart := ""
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil { if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
seenFlags := map[string]bool{} seenFlags := map[string]bool{}
for _, line := range strings.Split(string(data), "\n") { for _, line := range strings.Split(string(data), "\n") {
@@ -582,19 +644,28 @@ func detectHostCPUProbe() HostCPUProbe {
continue continue
} }
key := strings.TrimSpace(fields[0]) key := strings.TrimSpace(fields[0])
keyLower := strings.ToLower(key)
value := strings.TrimSpace(fields[1]) value := strings.TrimSpace(fields[1])
switch key { switch keyLower {
case "model name", "Hardware", "Processor": case "model name", "hardware", "processor":
if probe.Model == "" { if probe.Model == "" && meaningfulCPUModel(value) {
probe.Model = value probe.Model = value
} }
case "cpu cores": case "cpu cores":
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores { if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
probe.Cores = cores probe.Cores = cores
} }
case "flags", "Features": case "cpu implementer":
if armImplementer == "" {
armImplementer = strings.ToLower(value)
}
case "cpu part":
if armPart == "" {
armPart = strings.ToLower(value)
}
case "flags", "features":
for _, flag := range strings.Fields(value) { for _, flag := range strings.Fields(value) {
if flag == "vmx" || flag == "svm" { if flag == "vmx" || flag == "svm" || flag == "virt" {
probe.Virtualization = true probe.Virtualization = true
probe.VirtualizationKey = flag probe.VirtualizationKey = flag
} }
@@ -607,12 +678,132 @@ func detectHostCPUProbe() HostCPUProbe {
} }
sort.Strings(probe.Flags) sort.Strings(probe.Flags)
} }
enrichCPUProbeFromLscpu(&probe, &armImplementer, &armPart)
if probe.Model == "" {
probe.Model = armCPUModelName(armImplementer, armPart)
}
if probe.Model == "" && runtime.GOARCH == "arm64" {
probe.Model = "ARM64 CPU"
}
if probe.Model == "" { if probe.Model == "" {
probe.Model = "Unknown" probe.Model = "Unknown"
} }
return probe return probe
} }
func meaningfulCPUModel(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return false
}
if _, err := strconv.Atoi(value); err == nil {
return false
}
lower := strings.ToLower(value)
return lower != "unknown" && lower != "not specified"
}
func enrichCPUProbeFromLscpu(probe *HostCPUProbe, armImplementer *string, armPart *string) {
out := runCommandOutput(2*time.Second, "lscpu")
if out == "" {
return
}
for _, line := range strings.Split(out, "\n") {
fields := strings.SplitN(line, ":", 2)
if len(fields) != 2 {
continue
}
key := strings.ToLower(strings.TrimSpace(fields[0]))
value := strings.TrimSpace(fields[1])
switch key {
case "model name":
if probe.Model == "" && meaningfulCPUModel(value) {
probe.Model = value
}
case "cpu(s)":
if threads, err := strconv.Atoi(value); err == nil && threads > probe.Threads {
probe.Threads = threads
}
case "core(s) per socket":
if cores, err := strconv.Atoi(value); err == nil && cores > 0 {
probe.Cores = cores
}
case "socket(s)":
if sockets, err := strconv.Atoi(value); err == nil && sockets > 1 && probe.Cores > 0 {
probe.Cores *= sockets
}
case "virtualization":
lower := strings.ToLower(value)
if value != "" && lower != "none" && lower != "n/a" {
probe.Virtualization = true
probe.VirtualizationKey = value
}
case "flags":
seen := map[string]bool{}
for _, flag := range probe.Flags {
seen[flag] = true
}
for _, flag := range strings.Fields(value) {
if flag == "vmx" || flag == "svm" || flag == "virt" {
probe.Virtualization = true
probe.VirtualizationKey = flag
}
if !seen[flag] {
probe.Flags = append(probe.Flags, flag)
seen[flag] = true
}
}
sort.Strings(probe.Flags)
case "cpu implementer":
if *armImplementer == "" {
*armImplementer = strings.ToLower(value)
}
case "cpu part":
if *armPart == "" {
*armPart = strings.ToLower(value)
}
}
}
}
func armCPUModelName(implementer, part string) string {
implementer = normalizeHexID(implementer)
part = normalizeHexID(part)
if implementer == "" || part == "" {
return ""
}
armParts := map[string]string{
"0x41:0xd03": "ARM Cortex-A53",
"0x41:0xd05": "ARM Cortex-A55",
"0x41:0xd07": "ARM Cortex-A57",
"0x41:0xd08": "ARM Cortex-A72",
"0x41:0xd09": "ARM Cortex-A73",
"0x41:0xd0a": "ARM Cortex-A75",
"0x41:0xd0b": "ARM Cortex-A76",
"0x41:0xd0c": "ARM Neoverse N1",
"0x41:0xd0d": "ARM Cortex-A77",
"0x41:0xd40": "ARM Neoverse V1",
"0x41:0xd41": "ARM Cortex-A78",
"0x41:0xd49": "ARM Neoverse N2",
"0x41:0xd4f": "ARM Neoverse V2",
}
if model := armParts[implementer+":"+part]; model != "" {
return model
}
return strings.ToUpper(strings.TrimPrefix(implementer, "0x")) + " ARM CPU part " + part
}
func normalizeHexID(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
if strings.HasPrefix(value, "0x") {
return value
}
return "0x" + value
}
func detectMemoryModules() []HostMemoryModule { func detectMemoryModules() []HostMemoryModule {
if !commandExists("dmidecode") { if !commandExists("dmidecode") {
return nil return nil
@@ -724,7 +915,7 @@ func isVirtualBlockDevice(name, model, vendor string) bool {
} }
for _, token := range []string{ for _, token := range []string{
"qemu", "virtio", "virtual", "vmware", "vbox", "xen", "qemu", "virtio", "virtual", "vmware", "vbox", "xen",
"amazon elastic block store", "google persistentdisk", "microsoft", "amazon elastic block store", "google persistentdisk", "microsoft", "blockvolume",
} { } {
if strings.Contains(lower, token) { if strings.Contains(lower, token) {
return true return true
@@ -1153,9 +1344,126 @@ func detectAllPublicIPv4() []string {
result = append(result, value) result = append(result, value)
} }
} }
if egress := detectEgressPublicIPv4(); egress.Address != "" {
if !seen[egress.Address] {
seen[egress.Address] = true
result = append(result, egress.Address)
}
}
return result return result
} }
func detectDisplayPublicIPv4() lxc.PublicIPInfo {
if pub := lxc.DetectPublicIPv4(); pub.Address != "" {
return pub
}
return detectEgressPublicIPv4()
}
func detectEgressPublicIPv4() lxc.PublicIPInfo {
egressIPv4Mu.Lock()
defer egressIPv4Mu.Unlock()
if cachedEgressIPv4.Address != "" && time.Since(cachedEgressIPv4At) < 5*time.Minute {
return cachedEgressIPv4
}
client := &http.Client{Timeout: 1200 * time.Millisecond}
for _, endpoint := range []string{
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
} {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
cancel()
continue
}
resp, err := client.Do(req)
if err != nil {
cancel()
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 128))
_ = resp.Body.Close()
cancel()
if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
continue
}
address := strings.TrimSpace(string(body))
ip := net.ParseIP(address)
if !isPublicIPv4(ip) {
continue
}
iface, gateway := detectDefaultIPv4Route()
cachedEgressIPv4 = lxc.PublicIPInfo{
Address: ip.String(),
Interface: iface,
Prefix: ip.String() + "/32",
PrefixLen: 32,
SubnetMask: "255.255.255.255",
Gateway: gateway,
IsTunnel: isTunnelLikeInterfaceName(iface),
Source: "egress",
}
cachedEgressIPv4At = time.Now()
return cachedEgressIPv4
}
cachedEgressIPv4 = lxc.PublicIPInfo{}
cachedEgressIPv4At = time.Now()
return cachedEgressIPv4
}
func detectDefaultIPv4Route() (string, string) {
out := runCommandOutput(2*time.Second, "ip", "-4", "route", "show", "default")
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
iface := ""
gateway := ""
for i, field := range fields {
if field == "dev" && i+1 < len(fields) {
iface = fields[i+1]
}
if field == "via" && i+1 < len(fields) {
gateway = fields[i+1]
}
}
if iface != "" || gateway != "" {
return iface, gateway
}
}
return "", ""
}
func detectDefaultIPv6Route() (string, string) {
out := runCommandOutput(2*time.Second, "ip", "-6", "route", "show", "default")
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
iface := ""
gateway := ""
for i, field := range fields {
if field == "dev" && i+1 < len(fields) {
iface = fields[i+1]
}
if field == "via" && i+1 < len(fields) {
gateway = fields[i+1]
}
}
if iface != "" || gateway != "" {
return iface, gateway
}
}
return "", ""
}
func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe { func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe {
result := make([]HostIPProbe, 0) result := make([]HostIPProbe, 0)
for _, nic := range nics { for _, nic := range nics {
@@ -1301,6 +1609,16 @@ func isContainerLikeInterfaceName(iface string) bool {
return false return false
} }
func isTunnelLikeInterfaceName(iface string) bool {
lower := strings.ToLower(strings.TrimSpace(iface))
for _, prefix := range []string{"tun", "tap", "wg", "gre", "gretap", "sit", "ip6tnl", "he-", "zt", "tailscale"} {
if lower == prefix || strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe { func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe {
result := make([]HostIPProbe, 0) result := make([]HostIPProbe, 0)
for _, nic := range nics { for _, nic := range nics {
@@ -1368,6 +1686,8 @@ func detectGPUVendor(value string) string {
return "NVIDIA" return "NVIDIA"
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"): case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
return "AMD" return "AMD"
case strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu"):
return "Virtio"
default: default:
return "Unknown" return "Unknown"
} }
@@ -1375,6 +1695,9 @@ func detectGPUVendor(value string) string {
func detectGPUType(value string) string { func detectGPUType(value string) string {
lower := strings.ToLower(value) lower := strings.ToLower(value)
if strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu") {
return "virtual"
}
if strings.Contains(lower, "intel") { if strings.Contains(lower, "intel") {
return "integrated" return "integrated"
} }
@@ -1394,9 +1717,10 @@ func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe {
devKVM := fileExists("/dev/kvm") devKVM := fileExists("/dev/kvm")
nested, detail := detectNestedVirtualization() nested, detail := detectNestedVirtualization()
lxcOK := envCheckOK(env, "lxc-create") lxcOK := envCheckOK(env, "lxc-create")
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
probe := HostRuntimeProbe{ probe := HostRuntimeProbe{
LXCAvailable: lxcOK, LXCAvailable: lxcOK,
KVMAvailable: devKVM && envCheckOK(env, "virsh"), KVMAvailable: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()),
DevKVM: devKVM, DevKVM: devKVM,
NestedVirtualization: nested, NestedVirtualization: nested,
NestedDetail: detail, NestedDetail: detail,
@@ -1446,6 +1770,7 @@ func detectSystemProbe() HostSystemProbe {
} }
func detectHostEnvironment() []HostEnvCheck { func detectHostEnvironment() []HostEnvCheck {
qemuCheck := commandCheck(kvmQEMUCheckKey(), "QEMU/KVM 虚拟机", false, kvmQEMUCommand(), "")
checks := []HostEnvCheck{ checks := []HostEnvCheck{
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"), commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""), commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
@@ -1454,7 +1779,7 @@ func detectHostEnvironment() []HostEnvCheck {
commandCheck("ip", "iproute2 网络工具", true, "ip", ""), commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""), commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
commandCheck("virsh", "libvirt virsh", false, "virsh", ""), commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
commandCheck("qemu-system-x86_64", "QEMU/KVM 虚拟机", false, "qemu-system-x86_64", ""), qemuCheck,
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"), commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""), commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""), commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
@@ -1467,6 +1792,19 @@ func detectHostEnvironment() []HostEnvCheck {
return checks return checks
} }
func kvmQEMUCheckKey() string {
switch runtime.GOARCH {
case "arm64":
return "qemu-system-aarch64"
default:
return "qemu-system-x86_64"
}
}
func kvmQEMUCommand() string {
return kvmQEMUCheckKey()
}
func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck { func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck {
ok := commandExists(cmd) ok := commandExists(cmd)
detail := "missing" detail := "missing"
+34
View File
@@ -41,3 +41,37 @@ func TestCertbotVersionAtLeast54(t *testing.T) {
} }
} }
} }
func TestARMCPUModelName(t *testing.T) {
if got := armCPUModelName("0x41", "0xd0c"); got != "ARM Neoverse N1" {
t.Fatalf("armCPUModelName() = %q, want ARM Neoverse N1", got)
}
if got := armCPUModelName("41", "d0c"); got != "ARM Neoverse N1" {
t.Fatalf("armCPUModelName() without hex prefix = %q, want ARM Neoverse N1", got)
}
}
func TestMeaningfulCPUModel(t *testing.T) {
if meaningfulCPUModel("0") {
t.Fatal("numeric ARM processor index should not be treated as a CPU model")
}
if !meaningfulCPUModel("Neoverse-N1") {
t.Fatal("expected Neoverse-N1 to be treated as a CPU model")
}
}
func TestHostTrafficInterfaceFilter(t *testing.T) {
accepted := []string{"eth0", "ens3", "enp0s6", "bond0", "wg0"}
for _, name := range accepted {
if !isHostTrafficInterface(name) {
t.Fatalf("expected %s to be accepted as a host traffic interface", name)
}
}
rejected := []string{"", "lo", "docker0", "br-3024b78640ee", "lxcbr0", "virbr0", "vethaaa9e44", "cni0"}
for _, name := range rejected {
if isHostTrafficInterface(name) {
t.Fatalf("expected %s to be rejected as an internal/container interface", name)
}
}
}
+26 -2
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"sync" "sync"
"time" "time"
@@ -227,9 +228,14 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
enabledSet := getEnabledImageSet() enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors() cleanupOldImageDownloadErrors()
kvmAvailable := hostKVMAvailable()
templates := lxc.GetTemplates() templates := lxc.GetTemplates()
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages())) kvmImages := []kvm.Image{}
if kvmAvailable {
kvmImages = kvm.GetImages()
}
images := make([]ImageInfo, 0, len(templates)+len(kvmImages))
for _, t := range templates { for _, t := range templates {
dl := imageDownloadInfo(t.ID) dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
@@ -252,7 +258,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
SizeBytes: size, SizeBytes: size,
}) })
} }
for _, t := range kvm.GetImages() { for _, t := range kvmImages {
dl := imageDownloadInfo(t.ID) dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := "" manualPath := ""
@@ -309,6 +315,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return return
} }
if !hostKVMAvailable() {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
return
}
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok { if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
ensureImageEnabled(image.ID) ensureImageEnabled(image.ID)
clearImageDownload(image.ID) clearImageDownload(image.ID)
@@ -534,6 +544,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
result := make([]map[string]string, 0) result := make([]map[string]string, 0)
if runtime == config.VirtualizationKVM { if runtime == config.VirtualizationKVM {
if !hostKVMAvailable() {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
return
}
for _, t := range kvm.GetImages() { for _, t := range kvm.GetImages() {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded { if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
result = append(result, map[string]string{ result = append(result, map[string]string{
@@ -563,6 +577,9 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
func isImageEnabledAndDownloaded(templateID string, runtime string) bool { func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime) runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM { if runtime == config.VirtualizationKVM {
if !hostKVMAvailable() {
return false
}
image := kvm.FindImage(templateID) image := kvm.FindImage(templateID)
if image == nil { if image == nil {
return false return false
@@ -579,6 +596,13 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
} }
func hostKVMAvailable() bool {
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return false
}
return fileExists("/dev/kvm") && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
}
func ensureImageEnabled(id string) { func ensureImageEnabled(id string) {
// If the enabled list is empty, all templates are currently enabled by default. // If the enabled list is empty, all templates are currently enabled by default.
// We must populate the list with all template IDs first so that explicit toggles stick. // We must populate the list with all template IDs first so that explicit toggles stick.
+53 -37
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -125,32 +126,34 @@ var cliTranslations = map[string]string{
"检查仓库": "Checking repository", "检查仓库": "Checking repository",
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version", "检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.", "GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
"最新版本": "Latest version", "最新版本": "Latest version",
"发布页面": "Release page", "发布页面": "Release page",
"最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.", "当前架构不支持自动升级": "Automatic upgrade is not supported on the current architecture",
"当前已经是最新版本。": "The current version is already the latest.", "最新 Release 没有找到": "The latest release does not contain",
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue", "无法自动升级。": "automatic upgrade is unavailable.",
"输入 upgrade 开始升级": "Type upgrade to start upgrade", "当前已经是最新版本。": "The current version is already the latest.",
"已取消。": "Cancelled.", "是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
"升级失败": "Upgrade failed", "输入 upgrade 开始升级": "Type upgrade to start upgrade",
"升级完成": "Upgrade completed", "已取消。": "Cancelled.",
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.", "升级失败": "Upgrade failed",
"GitHub API 返回": "GitHub API returned", "升级完成": "Upgrade completed",
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.", "原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.", "GitHub API 返回": "GitHub API returned",
"GitHub releases/latest 返回": "GitHub releases/latest returned", "GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect", "GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
"正在下载升级包...": "Downloading upgrade package...", "GitHub releases/latest 返回": "GitHub releases/latest returned",
"正在解压升级包...": "Extracting upgrade package...", "无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
"解压失败": "Extraction failed", "正在下载升级包...": "Downloading upgrade package...",
"备份旧二进制失败": "Failed to back up old binary", "正在解压升级包...": "Extracting upgrade package...",
"旧版本已备份": "Old version backed up", "解压失败": "Extraction failed",
"正在替换二进制...": "Replacing binary...", "备份旧二进制失败": "Failed to back up old binary",
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt", "旧版本已备份": "Old version backed up",
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed", "正在替换二进制...": "Replacing binary...",
"下载失败,HTTP": "Download failed, HTTP", "停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package", "二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.", "下载失败,HTTP": "Download failed, HTTP",
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.", "导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
"导入失败": "Import failed", "导入失败": "Import failed",
"没有发现新的 ct-* 容器。": "No new ct-* containers found.", "没有发现新的 ct-* 容器。": "No new ct-* containers found.",
@@ -557,11 +560,16 @@ func cliUpgradeSystem(reader *bufio.Reader) {
if repo == "" { if repo == "" {
repo = version.Repo repo = version.Repo
} }
assetName, err := releaseArchiveAssetName(runtime.GOARCH)
if err != nil {
cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH)
return
}
current := version.Current() current := version.Current()
cliPrintf("当前版本: %s\n", current) cliPrintf("当前版本: %s\n", current)
cliPrintf("检查仓库: https://github.com/%s\n", repo) cliPrintf("检查仓库: https://github.com/%s\n", repo)
release, err := fetchLatestRelease(repo) release, err := fetchLatestRelease(repo, assetName)
if err != nil { if err != nil {
cliPrintf("检查 GitHub 最新版本失败: %v\n", err) cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
return return
@@ -576,9 +584,9 @@ func cliUpgradeSystem(reader *bufio.Reader) {
cliPrintf("发布页面: %s\n", release.HTMLURL) cliPrintf("发布页面: %s\n", release.HTMLURL)
} }
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz") assetURL := findReleaseAsset(release, assetName)
if assetURL == "" { if assetURL == "" {
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。") cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName)
return return
} }
@@ -597,7 +605,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
} }
} }
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil { if err := upgradeFromReleaseAsset(assetURL, latest, assetName); err != nil {
cliPrintf("升级失败: %v\n", err) cliPrintf("升级失败: %v\n", err)
return return
} }
@@ -605,7 +613,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
cliPrintln("原有数据已保留,Web 服务已重启。") cliPrintln("原有数据已保留,Web 服务已重启。")
} }
func fetchLatestRelease(repo string) (*githubRelease, error) { func fetchLatestRelease(repo, assetName string) (*githubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
req, err := http.NewRequest(http.MethodGet, url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
@@ -617,7 +625,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
client := &http.Client{Timeout: 20 * time.Second} client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil { if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
return fallback, nil return fallback, nil
} }
return nil, err return nil, err
@@ -627,7 +635,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body))) apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil { if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
cliPrintln("GitHub API 被限流,已切换到备用检查方式。") cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
} else { } else {
@@ -645,7 +653,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
return &release, nil return &release, nil
} }
func fetchLatestReleaseFallback(repo string) (*githubRelease, error) { func fetchLatestReleaseFallback(repo, assetName string) (*githubRelease, error) {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil) req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -667,7 +675,6 @@ func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本") return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
} }
const assetName = "clicd-linux-amd64.tar.gz"
return &githubRelease{ return &githubRelease{
TagName: tag, TagName: tag,
Name: tag, Name: tag,
@@ -708,6 +715,15 @@ func setGitHubRequestHeaders(req *http.Request) {
} }
} }
func releaseArchiveAssetName(goarch string) (string, error) {
switch goarch {
case "amd64", "arm64":
return fmt.Sprintf("clicd-linux-%s.tar.gz", goarch), nil
default:
return "", fmt.Errorf("unsupported architecture: %s", goarch)
}
}
func findReleaseAsset(release *githubRelease, name string) string { func findReleaseAsset(release *githubRelease, name string) string {
for _, asset := range release.Assets { for _, asset := range release.Assets {
if asset.Name == name && asset.BrowserDownloadURL != "" { if asset.Name == name && asset.BrowserDownloadURL != "" {
@@ -717,14 +733,14 @@ func findReleaseAsset(release *githubRelease, name string) string {
return "" return ""
} }
func upgradeFromReleaseAsset(assetURL, latest string) error { func upgradeFromReleaseAsset(assetURL, latest, assetName string) error {
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*") tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
if err != nil { if err != nil {
return err return err
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz") archivePath := filepath.Join(tmpDir, assetName)
cliPrintln("正在下载升级包...") cliPrintln("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil { if err := downloadFile(assetURL, archivePath); err != nil {
return err return err
+20
View File
@@ -19,6 +19,26 @@ func TestSafeReleaseBackupComponent(t *testing.T) {
} }
} }
func TestReleaseArchiveAssetName(t *testing.T) {
tests := map[string]string{
"amd64": "clicd-linux-amd64.tar.gz",
"arm64": "clicd-linux-arm64.tar.gz",
}
for goarch, want := range tests {
got, err := releaseArchiveAssetName(goarch)
if err != nil {
t.Fatalf("releaseArchiveAssetName(%q) error = %v", goarch, err)
}
if got != want {
t.Fatalf("releaseArchiveAssetName(%q) = %q, want %q", goarch, got, want)
}
}
if _, err := releaseArchiveAssetName("386"); err == nil {
t.Fatal("releaseArchiveAssetName(386) error = nil, want unsupported architecture")
}
}
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) { func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
unsafeNames := []string{ unsafeNames := []string{
"../clicd", "../clicd",
+67 -11
View File
@@ -20,6 +20,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -1538,7 +1539,13 @@ func (m *Manager) validateHost(skipCloudInit bool) error {
return err return err
} }
} }
if err := requireCommand(kvmEmulatorCommand()); err != nil {
return err
}
if skipCloudInit { if skipCloudInit {
if runtime.GOARCH != "amd64" {
return fmt.Errorf("Windows KVM is currently supported only on x86_64/amd64 hosts")
}
if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil { if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil {
return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err) return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err)
} }
@@ -1572,6 +1579,40 @@ func requireAnyCommand(names ...string) error {
return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", ")) return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", "))
} }
func kvmLibvirtArch() string {
switch runtime.GOARCH {
case "arm64":
return "aarch64"
default:
return "x86_64"
}
}
func kvmMachineType() string {
switch runtime.GOARCH {
case "arm64":
return "virt"
default:
return "pc"
}
}
func kvmEmulatorCommand() string {
switch runtime.GOARCH {
case "arm64":
return "qemu-system-aarch64"
default:
return "qemu-system-x86_64"
}
}
func kvmEmulatorPath() string {
if path, err := exec.LookPath(kvmEmulatorCommand()); err == nil {
return path
}
return "/usr/bin/" + kvmEmulatorCommand()
}
func ensureDefaultNetwork() error { func ensureDefaultNetwork() error {
// Ensure libvirtd is running // Ensure libvirtd is running
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil { if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
@@ -2186,6 +2227,26 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>" video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
input = "\n\t <input type='tablet' bus='usb'/>" input = "\n\t <input type='tablet' bus='usb'/>"
} }
osAttrs := ""
features := "<features><acpi/><apic/></features>"
if runtime.GOARCH == "arm64" {
osAttrs = " firmware='efi'"
features = "<features><acpi/><gic version='3'/></features>"
}
seedDisk := fmt.Sprintf(`<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='hdb' bus='ide'/>
<readonly/>
</disk>`, xmlEscape(seedPath))
if runtime.GOARCH == "arm64" {
seedDisk = fmt.Sprintf(`<disk type='file' device='disk'>
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='vdb' bus='virtio'/>
<readonly/>
</disk>`, xmlEscape(seedPath))
}
return fmt.Sprintf(`<domain type='kvm'> return fmt.Sprintf(`<domain type='kvm'>
<name>%s</name> <name>%s</name>
%s %s
@@ -2193,29 +2254,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<currentMemory unit='MiB'>%d</currentMemory> <currentMemory unit='MiB'>%d</currentMemory>
<vcpu placement='static' current='%d'>%d</vcpu> <vcpu placement='static' current='%d'>%d</vcpu>
<cputune><shares>2048</shares></cputune> <cputune><shares>2048</shares></cputune>
<os> <os%s>
<type arch='x86_64' machine='pc'>hvm</type> <type arch='%s' machine='%s'>hvm</type>
<boot dev='hd'/> <boot dev='hd'/>
</os> </os>
<features><acpi/><apic/></features> %s
<cpu mode='host-passthrough' check='none'/> <cpu mode='host-passthrough' check='none'/>
<clock offset='utc'/> <clock offset='utc'/>
<on_poweroff>destroy</on_poweroff> <on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot> <on_reboot>restart</on_reboot>
<on_crash>restart</on_crash> <on_crash>restart</on_crash>
<devices> <devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator> <emulator>%s</emulator>
<disk type='file' device='disk'> <disk type='file' device='disk'>
<driver name='qemu' type='qcow2' cache='none'/> <driver name='qemu' type='qcow2' cache='none'/>
<source file='%s'/> <source file='%s'/>
<target dev='vda' bus='virtio'/>%s <target dev='vda' bus='virtio'/>%s
</disk> </disk>
<disk type='file' device='cdrom'> %s
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='hdb' bus='ide'/>
<readonly/>
</disk>
<interface type='network'> <interface type='network'>
<mac address='%s'/> <mac address='%s'/>
<source network='default'/> <source network='default'/>
@@ -2232,7 +2288,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s <graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
%s %s
</devices> </devices>
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video) </domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, osAttrs, kvmLibvirtArch(), kvmMachineType(), features, xmlEscape(kvmEmulatorPath()), xmlEscape(diskPath), iotune, seedDisk, xmlEscape(mac), bandwidth, input, video)
} }
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string { func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string {
+57
View File
@@ -2,6 +2,7 @@ package kvm
import ( import (
"path/filepath" "path/filepath"
"runtime"
) )
type Image struct { type Image struct {
@@ -16,6 +17,15 @@ type Image struct {
} }
func GetImages() []Image { func GetImages() []Image {
switch runtime.GOARCH {
case "arm64":
return arm64Images()
default:
return amd64Images()
}
}
func amd64Images() []Image {
return []Image{ return []Image{
{ {
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM", ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
@@ -94,6 +104,53 @@ func GetImages() []Image {
} }
} }
func arm64Images() []Image {
return []Image{
{
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
Distro: "ubuntu", Release: "noble", Arch: "arm64",
Description: "Ubuntu 24.04 LTS cloud image for ARM64 KVM",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
},
{
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
Distro: "ubuntu", Release: "jammy", Arch: "arm64",
Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM",
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
},
{
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
Distro: "debian", Release: "bookworm", Arch: "arm64",
Description: "Debian 12 generic cloud image for ARM64 KVM",
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2",
},
{
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
Distro: "debian", Release: "bullseye", Arch: "arm64",
Description: "Debian 11 generic cloud image for ARM64 KVM",
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2",
},
{
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
Distro: "centos", Release: "9-stream", Arch: "arm64",
Description: "CentOS Stream 9 GenericCloud image for ARM64 KVM",
URL: "https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2",
},
{
ID: "kvm-fedora-44", Name: "Fedora 44 KVM",
Distro: "fedora", Release: "44", Arch: "arm64",
Description: "Fedora 44 GenericCloud image for ARM64 KVM",
URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2",
},
{
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
Distro: "rockylinux", Release: "9", Arch: "arm64",
Description: "Rocky Linux 9 GenericCloud image for ARM64 KVM",
URL: "https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud-Base.latest.aarch64.qcow2",
},
}
}
func FindImage(id string) *Image { func FindImage(id string) *Image {
for _, image := range GetImages() { for _, image := range GetImages() {
if image.ID == id { if image.ID == id {
+21 -9
View File
@@ -1,5 +1,7 @@
package lxc package lxc
import "runtime"
// Template represents an LXC image template // Template represents an LXC image template
type Template struct { type Template struct {
ID string `json:"id"` ID string `json:"id"`
@@ -13,55 +15,65 @@ type Template struct {
// GetTemplates returns available LXC image templates (only verified working ones) // GetTemplates returns available LXC image templates (only verified working ones)
func GetTemplates() []Template { func GetTemplates() []Template {
arch := defaultTemplateArch()
return []Template{ return []Template{
{ {
ID: "ubuntu-noble", Name: "Ubuntu 24.04", ID: "ubuntu-noble", Name: "Ubuntu 24.04",
Distro: "ubuntu", Release: "noble", Arch: "amd64", Distro: "ubuntu", Release: "noble", Arch: arch,
Description: "Ubuntu 24.04 LTS", Description: "Ubuntu 24.04 LTS",
}, },
{ {
ID: "ubuntu-jammy", Name: "Ubuntu 22.04", ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
Distro: "ubuntu", Release: "jammy", Arch: "amd64", Distro: "ubuntu", Release: "jammy", Arch: arch,
Description: "Ubuntu 22.04 LTS", Description: "Ubuntu 22.04 LTS",
}, },
{ {
ID: "debian-bookworm", Name: "Debian 12", ID: "debian-bookworm", Name: "Debian 12",
Distro: "debian", Release: "bookworm", Arch: "amd64", Distro: "debian", Release: "bookworm", Arch: arch,
Description: "Debian 12 (Bookworm)", Description: "Debian 12 (Bookworm)",
}, },
{ {
ID: "debian-bullseye", Name: "Debian 11", ID: "debian-bullseye", Name: "Debian 11",
Distro: "debian", Release: "bullseye", Arch: "amd64", Distro: "debian", Release: "bullseye", Arch: arch,
Description: "Debian 11 (Bullseye)", Description: "Debian 11 (Bullseye)",
}, },
{ {
ID: "alpine-3.21", Name: "Alpine 3.21", ID: "alpine-3.21", Name: "Alpine 3.21",
Distro: "alpine", Release: "3.21", Arch: "amd64", Distro: "alpine", Release: "3.21", Arch: arch,
Description: "Alpine Linux 3.21", Description: "Alpine Linux 3.21",
}, },
{ {
ID: "centos-9-stream", Name: "CentOS 9 Stream", ID: "centos-9-stream", Name: "CentOS 9 Stream",
Distro: "centos", Release: "9-Stream", Arch: "amd64", Distro: "centos", Release: "9-Stream", Arch: arch,
Description: "CentOS 9 Stream", Description: "CentOS 9 Stream",
}, },
{ {
ID: "archlinux-current", Name: "Arch Linux", ID: "archlinux-current", Name: "Arch Linux",
Distro: "archlinux", Release: "current", Arch: "amd64", Distro: "archlinux", Release: "current", Arch: arch,
Description: "Arch Linux (Rolling)", Description: "Arch Linux (Rolling)",
}, },
{ {
ID: "fedora-44", Name: "Fedora 44", ID: "fedora-44", Name: "Fedora 44",
Distro: "fedora", Release: "44", Arch: "amd64", Distro: "fedora", Release: "44", Arch: arch,
Description: "Fedora 44", Description: "Fedora 44",
}, },
{ {
ID: "rockylinux-10", Name: "Rocky Linux 10", ID: "rockylinux-10", Name: "Rocky Linux 10",
Distro: "rockylinux", Release: "10", Arch: "amd64", Distro: "rockylinux", Release: "10", Arch: arch,
Description: "Rocky Linux 10", Description: "Rocky Linux 10",
}, },
} }
} }
func defaultTemplateArch() string {
switch runtime.GOARCH {
case "arm64":
return "arm64"
default:
return "amd64"
}
}
// FindTemplate finds a template by ID // FindTemplate finds a template by ID
func FindTemplate(id string) *Template { func FindTemplate(id string) *Template {
templates := GetTemplates() templates := GetTemplates()
-1
View File
@@ -1 +0,0 @@

+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.21" Version = "1.1.22"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+41 -3
View File
@@ -6,6 +6,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build" BUILD_DIR="$SCRIPT_DIR/build"
DIST_DIR="$SCRIPT_DIR/dist"
FRONTEND_DIR="$SCRIPT_DIR/frontend" FRONTEND_DIR="$SCRIPT_DIR/frontend"
BACKEND_DIR="$SCRIPT_DIR/backend" BACKEND_DIR="$SCRIPT_DIR/backend"
WEB_DIR="$SCRIPT_DIR/web" WEB_DIR="$SCRIPT_DIR/web"
@@ -17,9 +18,11 @@ echo "====================================="
# Clean previous build # Clean previous build
rm -rf "$BUILD_DIR" rm -rf "$BUILD_DIR"
rm -rf "$DIST_DIR"
rm -rf "$WEB_DIR" rm -rf "$WEB_DIR"
rm -rf "$EMBED_WEB_DIR" rm -rf "$EMBED_WEB_DIR"
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
mkdir -p "$DIST_DIR"
mkdir -p "$WEB_DIR" mkdir -p "$WEB_DIR"
mkdir -p "$EMBED_WEB_DIR" mkdir -p "$EMBED_WEB_DIR"
touch "$EMBED_WEB_DIR/.gitkeep" touch "$EMBED_WEB_DIR/.gitkeep"
@@ -51,9 +54,26 @@ cd "$BACKEND_DIR"
go mod tidy go mod tidy
go mod download go mod download
# Build for Linux amd64
BUILD_VERSION="${CLICD_VERSION:-dev}" BUILD_VERSION="${CLICD_VERSION:-dev}"
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" . TARGET_GOOS="${CLICD_GOOS:-linux}"
TARGET_GOARCH="${CLICD_GOARCH:-amd64}"
case "$TARGET_GOARCH" in
all) TARGET_GOARCH_LIST="amd64 arm64" ;;
amd64|arm64) TARGET_GOARCH_LIST="$TARGET_GOARCH" ;;
*)
echo "Unsupported CLICD_GOARCH: $TARGET_GOARCH (expected amd64, arm64, or all)" >&2
exit 2
;;
esac
for arch in $TARGET_GOARCH_LIST; do
echo "Target: ${TARGET_GOOS}/${arch}"
GOOS="$TARGET_GOOS" GOARCH="$arch" CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd-linux-${arch}" .
done
first_arch="${TARGET_GOARCH_LIST%% *}"
cp "$BUILD_DIR/clicd-linux-${first_arch}" "$BUILD_DIR/clicd"
echo "Go backend built successfully" echo "Go backend built successfully"
@@ -62,7 +82,20 @@ echo ""
echo "[3/3] Packaging..." echo "[3/3] Packaging..."
cp -r "$WEB_DIR" "$BUILD_DIR/web" cp -r "$WEB_DIR" "$BUILD_DIR/web"
cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true
chmod +x "$BUILD_DIR/clicd" chmod +x "$BUILD_DIR"/clicd*
for arch in $TARGET_GOARCH_LIST; do
asset_dir="clicd-linux-${arch}"
package_root="$BUILD_DIR/package-${arch}"
rm -rf "$package_root"
mkdir -p "$package_root/$asset_dir"
cp "$BUILD_DIR/clicd-linux-${arch}" "$package_root/$asset_dir/clicd"
cp "$BUILD_DIR/install.sh" "$package_root/$asset_dir/install.sh" 2>/dev/null || true
chmod +x "$package_root/$asset_dir/clicd"
[ ! -f "$package_root/$asset_dir/install.sh" ] || chmod +x "$package_root/$asset_dir/install.sh"
tar -C "$package_root" -czf "$DIST_DIR/${asset_dir}.tar.gz" "$asset_dir"
cp "$BUILD_DIR/clicd-linux-${arch}" "$DIST_DIR/${asset_dir}"
done
echo "" echo ""
echo "=====================================" echo "====================================="
@@ -70,6 +103,11 @@ echo " Build Complete!"
echo "=====================================" echo "====================================="
echo " Output: $BUILD_DIR/clicd" echo " Output: $BUILD_DIR/clicd"
echo " Web: $BUILD_DIR/web/" echo " Web: $BUILD_DIR/web/"
echo " Dist: $DIST_DIR/"
for arch in $TARGET_GOARCH_LIST; do
echo " dist/clicd-linux-${arch}"
echo " dist/clicd-linux-${arch}.tar.gz"
done
echo "" echo ""
echo " To deploy:" echo " To deploy:"
echo " 1. Copy build/ directory to server" echo " 1. Copy build/ directory to server"
+19
View File
@@ -30,6 +30,25 @@ bash build.sh
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。 该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
默认目标为 Linux amd64。需要构建 ARM64 包时可以指定:
```bash
CLICD_GOARCH=arm64 bash build.sh
```
需要同时构建 amd64 和 arm64 发布包时:
```bash
CLICD_GOARCH=all bash build.sh
```
构建完成后会生成:
- `dist/clicd-linux-amd64`
- `dist/clicd-linux-amd64.tar.gz`
- `dist/clicd-linux-arm64`
- `dist/clicd-linux-arm64.tar.gz`
## 文档站构建 ## 文档站构建
```bash ```bash
+3 -1
View File
@@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语
## Release 产物 ## Release 产物
安装脚本会优先下载 Linux AMD64 产物: 安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物:
```text ```text
clicd-linux-amd64.tar.gz clicd-linux-amd64.tar.gz
clicd-linux-arm64.tar.gz
``` ```
在部分场景中也会尝试下载单独二进制: 在部分场景中也会尝试下载单独二进制:
```text ```text
clicd-linux-amd64 clicd-linux-amd64
clicd-linux-arm64
``` ```
## 安装脚本行为 ## 安装脚本行为
+19
View File
@@ -30,6 +30,25 @@ bash build.sh
The script chains frontend build, static asset sync, and Go binary build. The script chains frontend build, static asset sync, and Go binary build.
The default target is Linux amd64. To build an ARM64 package, set:
```bash
CLICD_GOARCH=arm64 bash build.sh
```
To build both amd64 and arm64 release assets at once:
```bash
CLICD_GOARCH=all bash build.sh
```
The build writes:
- `dist/clicd-linux-amd64`
- `dist/clicd-linux-amd64.tar.gz`
- `dist/clicd-linux-arm64`
- `dist/clicd-linux-arm64.tar.gz`
## Docs Build ## Docs Build
```bash ```bash
+3 -1
View File
@@ -12,16 +12,18 @@ Check the version in:
## Release Artifacts ## Release Artifacts
The installer first tries to download the Linux AMD64 archive: The installer first tries to download the Linux AMD64 or ARM64 archive for the host architecture:
```text ```text
clicd-linux-amd64.tar.gz clicd-linux-amd64.tar.gz
clicd-linux-arm64.tar.gz
``` ```
In some cases, it may also try the standalone binary: In some cases, it may also try the standalone binary:
```text ```text
clicd-linux-amd64 clicd-linux-amd64
clicd-linux-arm64
``` ```
## Installer Behavior ## Installer Behavior
+2 -2
View File
@@ -4,7 +4,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
## Requirements ## Requirements
- Linux x86_64 host. - Linux x86_64/amd64 or ARM64/aarch64 host.
- Root privileges. - Root privileges.
- systemd. - systemd.
- Network access to GitHub Release downloads. - Network access to GitHub Release downloads.
@@ -17,7 +17,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
``` ```
The script defaults to `CLICD_VERSION=latest`, which downloads `clicd-linux-amd64.tar.gz` from `releases/latest`. The script defaults to `CLICD_VERSION=latest` and downloads `clicd-linux-amd64.tar.gz` or `clicd-linux-arm64.tar.gz` from `releases/latest` according to the host architecture.
## Install a Specific Version ## Install a Specific Version
+1 -1
View File
@@ -26,4 +26,4 @@ CLICD is a lightweight virtualization management panel for LXC and KVM. It bring
- Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack. - Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack.
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC. - Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC.
- Release: GitHub Actions builds Linux AMD64 release artifacts. The installer fetches the latest release by default. - Release: GitHub Actions builds Linux AMD64/ARM64 release artifacts. The installer fetches the latest release by default.
+1 -1
View File
@@ -2,7 +2,7 @@
## Which version does the installer install by default? ## Which version does the installer install by default?
It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 artifact from `releases/latest`. It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 or ARM64 artifact from `releases/latest` according to the host architecture.
## Can I pin a specific version? ## Can I pin a specific version?
+2 -2
View File
@@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
## 环境要求 ## 环境要求
- Linux x86_64 宿主机。 - Linux x86_64/amd64 或 ARM64/aarch64 宿主机。
- root 权限。 - root 权限。
- systemd。 - systemd。
- 网络可访问 GitHub Release 下载地址。 - 网络可访问 GitHub Release 下载地址。
@@ -17,7 +17,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
``` ```
脚本当前默认使用 `CLICD_VERSION=latest`也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz` 脚本当前默认使用 `CLICD_VERSION=latest`会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz``clicd-linux-arm64.tar.gz`
## 安装指定版本 ## 安装指定版本
+1 -1
View File
@@ -26,4 +26,4 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。 - 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。 - 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。 - 发布:GitHub Actions 构建 Linux AMD64/ARM64 release 产物,安装脚本默认拉取最新 Release。
+1 -1
View File
@@ -2,7 +2,7 @@
## 安装脚本默认安装哪个版本? ## 安装脚本默认安装哪个版本?
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。 默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。
## 可以固定安装某个版本吗? ## 可以固定安装某个版本吗?
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.21", "version": "1.1.22",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -98,6 +98,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const manualIPv4s = form.public_ipv4s || [] const manualIPv4s = form.public_ipv4s || []
const maxVCPU = hostInfo?.cpu.cores || 64 const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
useEffect(() => {
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))
}
}, [hostInfo, kvmAvailable, form.virtualization])
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB) const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const natEnabled = form.assign_nat !== false const natEnabled = form.assign_nat !== false
@@ -241,8 +248,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</button> </button>
<button <button
type="button" type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))} disabled={!kvmAvailable}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`} title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
onClick={() => {
if (kvmAvailable) {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))
}
}}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
> >
KVM KVM
</button> </button>
@@ -408,7 +421,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<span className="min-w-0"> <span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span> <span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
<span className="block text-xs text-gray-500 truncate"> <span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)} {ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)}
</span> </span>
</span> </span>
</label> </label>
@@ -762,7 +775,7 @@ const createNetworkText = {
zh: { zh: {
publicIPv4: '公网 IPv4', publicIPv4: '公网 IPv4',
noAllocatableIPv4: '未检测到可分配公网 IPv4', noAllocatableIPv4: '未检测到可分配公网 IPv4',
publicIPv6: '公网 IPv6', publicIPv6: '可分配 IPv6 前缀',
use: '使用', use: '使用',
checkingIPv6Prefix: '正在检测 IPv6 前缀...', checkingIPv6Prefix: '正在检测 IPv6 前缀...',
publicNAT: '公网 NAT', publicNAT: '公网 NAT',
@@ -771,7 +784,7 @@ const createNetworkText = {
en: { en: {
publicIPv4: 'Public IPv4', publicIPv4: 'Public IPv4',
noAllocatableIPv4: 'No allocatable public IPv4 detected', noAllocatableIPv4: 'No allocatable public IPv4 detected',
publicIPv6: 'Public IPv6', publicIPv6: 'Allocatable IPv6 Prefix',
use: 'Use', use: 'Use',
checkingIPv6Prefix: 'Checking IPv6 prefix...', checkingIPv6Prefix: 'Checking IPv6 prefix...',
publicNAT: 'Public NAT', publicNAT: 'Public NAT',
@@ -779,6 +792,21 @@ const createNetworkText = {
}, },
} as const } as const
function formatIPv6StatusReason(reason: string | undefined, language: Language, fallback: string) {
if (!reason) return fallback
if (reason.includes('/128 single-address IPv6 is not assignable')) {
return language === 'en'
? 'No allocatable IPv6 prefix. The host only has a /128 single IPv6 address.'
: '未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。'
}
if (reason.includes('outbound IPv6 connectivity test failed')) {
return language === 'en'
? reason
: '宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。'
}
return reason
}
function formatAllocatableIPv4Count(count: number, language: Language) { function formatAllocatableIPv4Count(count: number, language: Language) {
return language === 'en' return language === 'en'
? `${count} allocatable address${count === 1 ? '' : 'es'} detected` ? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
+1 -3
View File
@@ -395,10 +395,8 @@ export default function Containers() {
const isPlaceholder = !!container.isPlaceholder const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name] const usage = usageByName[container.name]
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const cpuPct = isRunning const cpuPct = isRunning
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1)) ? clamp((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))
: 0 : 0
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0 const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
? usage.memory_total_bytes ? usage.memory_total_bytes
+3 -2
View File
@@ -205,7 +205,7 @@ const hostReportText = {
ipv4Address: 'IPv4 地址', ipv4Address: 'IPv4 地址',
ipv4Prefix: 'IPv4 段', ipv4Prefix: 'IPv4 段',
ipv6Address: 'IPv6 地址', ipv6Address: 'IPv6 地址',
ipv6Prefix: 'IPv6 ', ipv6Prefix: '可分配 IPv6 前缀',
gateway: '网关', gateway: '网关',
memoryModules: '内存条', memoryModules: '内存条',
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限', noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
@@ -277,7 +277,7 @@ const hostReportText = {
ipv4Address: 'IPv4 Addresses', ipv4Address: 'IPv4 Addresses',
ipv4Prefix: 'IPv4 Prefixes', ipv4Prefix: 'IPv4 Prefixes',
ipv6Address: 'IPv6 Addresses', ipv6Address: 'IPv6 Addresses',
ipv6Prefix: 'IPv6 Prefixes', ipv6Prefix: 'Allocatable IPv6 Prefixes',
gateway: 'Gateway', gateway: 'Gateway',
memoryModules: 'Memory Modules', memoryModules: 'Memory Modules',
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.', noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
@@ -511,6 +511,7 @@ function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boole
} }
function gpuTypeLabel(value: string, language: Language) { function gpuTypeLabel(value: string, language: Language) {
if (value === 'virtual') return language === 'en' ? 'Virtual' : '虚拟'
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显' if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显' if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
return value || '-' return value || '-'
+13 -11
View File
@@ -148,17 +148,19 @@ export default function ImageManagement() {
onToggle={handleToggle} onToggle={handleToggle}
/> />
<ImageTable {kvmImages.length > 0 && (
title="KVM 虚拟机镜像" <ImageTable
images={kvmImages} title="KVM 虚拟机镜像"
actionLoading={actionLoading} images={kvmImages}
downloadedCount={kvmImages.filter((img) => img.downloaded).length} actionLoading={actionLoading}
totalCount={kvmImages.length} downloadedCount={kvmImages.filter((img) => img.downloaded).length}
onDownload={handleDownload} totalCount={kvmImages.length}
onCancelDownload={handleCancelDownload} onDownload={handleDownload}
onDelete={handleDelete} onCancelDownload={handleCancelDownload}
onToggle={handleToggle} onDelete={handleDelete}
/> onToggle={handleToggle}
/>
)}
</div> </div>
) )
} }
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.21</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.22</p>
</div> </div>
</div> </div>
) )
+8
View File
@@ -235,6 +235,14 @@ export interface HostInfo {
} }
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number } disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
load: { load1: number; load5: number; load15: number } load: { load1: number; load5: number; load15: number }
runtime?: {
lxc_available: boolean
kvm_available: boolean
dev_kvm: boolean
nested_virtualization: boolean
nested_detail: string
support_mode: string
}
} }
export interface HostProbeReport { export interface HostProbeReport {
+169 -47
View File
@@ -3,7 +3,6 @@ set -eu
REPO="${CLICD_REPO:-MengMengCode/CLICD}" REPO="${CLICD_REPO:-MengMengCode/CLICD}"
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}" CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
ASSET="clicd-linux-amd64.tar.gz"
ACTION="${1:-install}" ACTION="${1:-install}"
ACTION_CONFIRM="${2:-}" ACTION_CONFIRM="${2:-}"
ISSUE_URL="https://github.com/${REPO}/issues" ISSUE_URL="https://github.com/${REPO}/issues"
@@ -11,6 +10,80 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}" INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created" LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
normalize_clicd_arch() {
arch="$1"
case "$(printf '%s' "$arch" | tr 'A-Z' 'a-z')" in
x86_64|amd64) echo amd64 ;;
aarch64|arm64) echo arm64 ;;
*) echo "" ;;
esac
}
HOST_ARCH_RAW="$(uname -m 2>/dev/null || echo unknown)"
CLICD_ARCH_NORMALIZED="$(normalize_clicd_arch "${CLICD_ARCH:-$HOST_ARCH_RAW}")"
ASSET_DIR="clicd-linux-${CLICD_ARCH_NORMALIZED:-unknown}"
ASSET="${ASSET_DIR}.tar.gz"
BINARY_ASSET="$ASSET_DIR"
kvm_supported_arch() {
[ "$CLICD_ARCH_NORMALIZED" = "amd64" ] || [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]
}
warn_kvm_unsupported_arch() {
if ! kvm_supported_arch; then
warn "当前架构 ${CLICD_ARCH_NORMALIZED:-unknown} 已适配 CLICD/LXCKVM 功能当前支持 x86_64/amd64 和 aarch64/arm64,将跳过 KVM 专用依赖。"
fi
}
qemu_system_package_apk() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-system-x86_64 ;;
esac
}
qemu_system_package_apt() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-arm ;;
*) echo qemu-system-x86 ;;
esac
}
qemu_system_package_rpm() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-kvm ;;
esac
}
qemu_emulator_cmd() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-system-x86_64 ;;
esac
}
qemu_efi_package_apt() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-efi-aarch64 ;;
*) echo ovmf ;;
esac
}
qemu_efi_package_apk() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo edk2-aarch64 ;;
*) echo ovmf ;;
esac
}
qemu_efi_package_rpm() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo edk2-aarch64 ;;
*) echo edk2-ovmf ;;
esac
}
normalize_lang() { normalize_lang() {
lang="$1" lang="$1"
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
@@ -286,14 +359,8 @@ run_step() {
} }
check_os_compatibility() { check_os_compatibility() {
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)" log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=${HOST_ARCH_RAW} CLICD_ARCH=${CLICD_ARCH_NORMALIZED:-unsupported}"
case "$(uname -m 2>/dev/null || echo unknown)" in [ -n "$CLICD_ARCH_NORMALIZED" ] || die "当前安装包支持 x86_64/amd64 和 aarch64/arm64,当前架构:${HOST_ARCH_RAW}"
x86_64|amd64)
;;
*)
die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)"
;;
esac
if ! is_systemd && ! is_openrc; then if ! is_systemd && ! is_openrc; then
die "未检测到 systemd 或 OpenRC,无法安装服务。" die "未检测到 systemd 或 OpenRC,无法安装服务。"
fi fi
@@ -476,7 +543,16 @@ remove_clicd_lxc_image_cache() {
"centos 9-Stream amd64" \ "centos 9-Stream amd64" \
"archlinux current amd64" \ "archlinux current amd64" \
"fedora 44 amd64" \ "fedora 44 amd64" \
"rockylinux 10 amd64" "rockylinux 10 amd64" \
"ubuntu noble arm64" \
"ubuntu jammy arm64" \
"debian bookworm arm64" \
"debian bullseye arm64" \
"alpine 3.21 arm64" \
"centos 9-Stream arm64" \
"archlinux current arm64" \
"fedora 44 arm64" \
"rockylinux 10 arm64"
do do
set -- $image set -- $image
distro="$1" distro="$1"
@@ -950,15 +1026,21 @@ install_apk() {
iproute2 \ iproute2 \
iptables \ iptables \
dnsmasq \ dnsmasq \
dbus \ dbus
qemu-system-x86_64 \
if kvm_supported_arch; then
apk add --no-cache \
"$(qemu_system_package_apk)" \
qemu-img \ qemu-img \
libvirt \ libvirt \
libvirt-daemon \ libvirt-daemon \
libvirt-client \ libvirt-client \
libvirt-qemu libvirt-qemu
else
warn_kvm_unsupported_arch
fi
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools; do for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools "$(qemu_efi_package_apk)"; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
@@ -985,18 +1067,39 @@ install_apt() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq-base \ dnsmasq-base
qemu-kvm \
qemu-system-x86 \ if kvm_supported_arch; then
qemu-utils \ if [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]; then
libvirt-daemon-system \ apt-get install -y \
libvirt-clients \ "$(qemu_system_package_apt)" \
cloud-image-utils \ qemu-utils \
genisoimage \ libvirt-daemon-system \
xorriso \ libvirt-clients \
smartmontools \ cloud-image-utils \
virtinst \ genisoimage \
ovmf xorriso \
smartmontools \
virtinst \
"$(qemu_efi_package_apt)"
else
apt-get install -y \
qemu-kvm \
"$(qemu_system_package_apt)" \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
smartmontools \
virtinst \
"$(qemu_efi_package_apt)"
fi
else
warn_kvm_unsupported_arch
apt-get install -y qemu-utils genisoimage xorriso smartmontools >/dev/null 2>&1 || true
fi
} }
enable_el_repos() { enable_el_repos() {
@@ -1032,8 +1135,11 @@ install_dnf() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq \ dnsmasq
qemu-kvm \
if kvm_supported_arch; then
dnf install -y \
"$(qemu_system_package_rpm)" \
qemu-img \ qemu-img \
libvirt \ libvirt \
libvirt-daemon-kvm \ libvirt-daemon-kvm \
@@ -1041,8 +1147,12 @@ install_dnf() {
virt-install \ virt-install \
cloud-utils \ cloud-utils \
genisoimage genisoimage
else
warn_kvm_unsupported_arch
dnf install -y qemu-img genisoimage >/dev/null 2>&1 || true
fi
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
@@ -1067,8 +1177,11 @@ install_yum() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq \ dnsmasq
qemu-kvm \
if kvm_supported_arch; then
yum install -y \
"$(qemu_system_package_rpm)" \
qemu-img \ qemu-img \
libvirt \ libvirt \
libvirt-daemon-kvm \ libvirt-daemon-kvm \
@@ -1076,8 +1189,12 @@ install_yum() {
virt-install \ virt-install \
cloud-utils \ cloud-utils \
genisoimage genisoimage
else
warn_kvm_unsupported_arch
yum install -y qemu-img genisoimage >/dev/null 2>&1 || true
fi
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done done
} }
@@ -1117,14 +1234,19 @@ install_dependencies() {
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。" has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。" has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。" has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。" if kvm_supported_arch; then
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。" has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。" has_cmd "$(qemu_emulator_cmd)" || die "依赖安装后仍未找到 $(qemu_emulator_cmd),请检查 QEMU 安装。"
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。" has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
fi if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
if [ ! -e /dev/kvm ]; then die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。"
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。" fi
if [ ! -e /dev/kvm ]; then
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
fi
else
warn_kvm_unsupported_arch
fi fi
} }
@@ -1384,7 +1506,7 @@ download_release_if_needed() {
if [ "$archive_ok" = "1" ]; then if [ "$archive_ok" = "1" ]; then
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path" tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
else else
binary_asset="clicd-linux-amd64" binary_asset="$BINARY_ASSET"
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}" binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
else else
@@ -1402,9 +1524,9 @@ download_release_if_needed() {
[ -n "$url" ] || continue [ -n "$url" ] || continue
log "Trying release binary: $url" log "Trying release binary: $url"
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
mkdir -p "$tmp_dir/clicd-linux-amd64" mkdir -p "$tmp_dir/$ASSET_DIR"
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd" cp "$binary_path" "$tmp_dir/$ASSET_DIR/clicd"
chmod +x "$tmp_dir/clicd-linux-amd64/clicd" chmod +x "$tmp_dir/$ASSET_DIR/clicd"
binary_ok=1 binary_ok=1
break break
fi fi
@@ -1414,8 +1536,8 @@ download_release_if_needed() {
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url" [ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
fi fi
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory" [ -d "$tmp_dir/$ASSET_DIR" ] || die "Release package layout is invalid: missing $ASSET_DIR directory"
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。" [ -f "$tmp_dir/$ASSET_DIR/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
} }
install_binary() { install_binary() {
@@ -1430,8 +1552,8 @@ install_binary() {
download_dir="" download_dir=""
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)" download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then if [ -n "$download_dir" ] && [ -f "$download_dir/$ASSET_DIR/clicd" ]; then
bin_src="$download_dir/clicd-linux-amd64/clicd" bin_src="$download_dir/$ASSET_DIR/clicd"
fi fi
fi fi
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。" [ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"