From 61137b837d84b63225644cede9ebcdf5efe838d9 Mon Sep 17 00:00:00 2001
From: MengMengCode <227010654+MengMengCode@users.noreply.github.com>
Date: Thu, 16 Jul 2026 16:52:15 +0800
Subject: [PATCH] release: v1.1.22
---
.github/workflows/build.yml | 48 ++-
.gitignore | 2 +
backend/internal/api/host.go | 372 +++++++++++++++++-
backend/internal/api/host_test.go | 34 ++
backend/internal/api/images.go | 28 +-
backend/internal/cli/cli.go | 90 +++--
backend/internal/cli/cli_test.go | 20 +
backend/internal/kvm/kvm.go | 78 +++-
backend/internal/kvm/templates.go | 57 +++
backend/internal/lxc/templates.go | 30 +-
backend/internal/server/web/.gitkeep | 1 -
backend/internal/version/version.go | 2 +-
build.sh | 44 ++-
docs/developer/build.md | 19 +
docs/developer/release.md | 4 +-
docs/en/developer/build.md | 19 +
docs/en/developer/release.md | 4 +-
docs/en/guide/installation.md | 4 +-
docs/en/guide/introduction.md | 2 +-
docs/en/operations/faq.md | 2 +-
docs/guide/installation.md | 4 +-
docs/guide/introduction.md | 2 +-
docs/operations/faq.md | 2 +-
frontend/package.json | 2 +-
.../src/components/CreateContainerModal.tsx | 38 +-
frontend/src/pages/Containers.tsx | 4 +-
frontend/src/pages/HostReport.tsx | 5 +-
frontend/src/pages/ImageManagement.tsx | 24 +-
frontend/src/pages/Login.tsx | 2 +-
frontend/src/services/api.ts | 8 +
install.sh | 216 +++++++---
31 files changed, 996 insertions(+), 171 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2293ca6..c7c6f70 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,9 +14,15 @@ permissions:
contents: write
jobs:
- linux-amd64:
- name: Linux amd64
+ linux:
+ name: Linux ${{ matrix.goarch }}
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ goarch:
+ - amd64
+ - arm64
steps:
- name: Checkout
@@ -55,16 +61,21 @@ jobs:
fi
- name: Build CLICD
+ env:
+ CLICD_GOARCH: ${{ matrix.goarch }}
run: bash build.sh
- name: Package CLICD
+ env:
+ CLICD_GOARCH: ${{ matrix.goarch }}
run: |
- mkdir -p dist package/clicd-linux-amd64
- cp build/clicd package/clicd-linux-amd64/clicd
- cp build/install.sh package/clicd-linux-amd64/install.sh
- chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh
- tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64
- cp build/clicd dist/clicd-linux-amd64
+ asset_dir="clicd-linux-${CLICD_GOARCH}"
+ mkdir -p "dist" "package/${asset_dir}"
+ cp build/clicd "package/${asset_dir}/clicd"
+ cp build/install.sh "package/${asset_dir}/install.sh"
+ chmod +x "package/${asset_dir}/clicd" "package/${asset_dir}/install.sh"
+ tar -C package -czf "dist/${asset_dir}.tar.gz" "${asset_dir}"
+ cp build/clicd "dist/${asset_dir}"
- name: Package Mofang module
run: |
@@ -87,11 +98,28 @@ jobs:
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
- name: clicd-linux-amd64
+ name: clicd-linux-${{ matrix.goarch }}
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
- if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
run: |
diff --git a/.gitignore b/.gitignore
index 5d6d8ba..2493b83 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ backend/internal/server/web/*
# Build artifacts
/build/
+/dist/
Mofang/*.zip
*.exe
*.dll
@@ -69,3 +70,4 @@ push-release.ps1
deploy.ps1
backend/clicd
api.md
+deploy-arm.ps1
diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go
index 430f647..1c5dc05 100644
--- a/backend/internal/api/host.go
+++ b/backend/internal/api/host.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"net"
"net/http"
"os"
@@ -219,6 +220,9 @@ var hostCPUMu sync.Mutex
var lastHostCPU cpuTimes
var hostIOMu sync.Mutex
var lastHostIO hostIOSample
+var egressIPv4Mu sync.Mutex
+var cachedEgressIPv4 lxc.PublicIPInfo
+var cachedEgressIPv4At time.Time
type cpuTimes struct {
Total uint64
@@ -397,7 +401,7 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
now := unixNano()
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
- publicIPv4 := lxc.DetectPublicIPv4()
+ publicIPv4 := detectDisplayPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
@@ -437,23 +441,79 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
}
func readHostNetworkBytes() (uint64, uint64) {
- entries, err := os.ReadDir("/sys/class/net")
- if err != nil {
- return 0, 0
+ ifaces := detectHostTrafficInterfaces()
+ if len(ifaces) == 0 {
+ ifaces = fallbackHostTrafficInterfaces()
}
var rx, tx uint64
- for _, entry := range entries {
- name := entry.Name()
- if name == "lo" {
- continue
- }
+ for _, name := range ifaces {
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
}
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) {
f, err := os.Open("/proc/diskstats")
if err != nil {
@@ -574,6 +634,8 @@ func trimOSReleaseValue(value string) string {
func detectHostCPUProbe() HostCPUProbe {
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
+ armImplementer := ""
+ armPart := ""
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
seenFlags := map[string]bool{}
for _, line := range strings.Split(string(data), "\n") {
@@ -582,19 +644,28 @@ func detectHostCPUProbe() HostCPUProbe {
continue
}
key := strings.TrimSpace(fields[0])
+ keyLower := strings.ToLower(key)
value := strings.TrimSpace(fields[1])
- switch key {
- case "model name", "Hardware", "Processor":
- if probe.Model == "" {
+ switch keyLower {
+ case "model name", "hardware", "processor":
+ if probe.Model == "" && meaningfulCPUModel(value) {
probe.Model = value
}
case "cpu cores":
if cores, err := strconv.Atoi(value); err == nil && cores > probe.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) {
- if flag == "vmx" || flag == "svm" {
+ if flag == "vmx" || flag == "svm" || flag == "virt" {
probe.Virtualization = true
probe.VirtualizationKey = flag
}
@@ -607,12 +678,132 @@ func detectHostCPUProbe() HostCPUProbe {
}
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 == "" {
probe.Model = "Unknown"
}
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 {
if !commandExists("dmidecode") {
return nil
@@ -724,7 +915,7 @@ func isVirtualBlockDevice(name, model, vendor string) bool {
}
for _, token := range []string{
"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) {
return true
@@ -1153,9 +1344,126 @@ func detectAllPublicIPv4() []string {
result = append(result, value)
}
}
+ if egress := detectEgressPublicIPv4(); egress.Address != "" {
+ if !seen[egress.Address] {
+ seen[egress.Address] = true
+ result = append(result, egress.Address)
+ }
+ }
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 {
result := make([]HostIPProbe, 0)
for _, nic := range nics {
@@ -1301,6 +1609,16 @@ func isContainerLikeInterfaceName(iface string) bool {
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 {
result := make([]HostIPProbe, 0)
for _, nic := range nics {
@@ -1368,6 +1686,8 @@ func detectGPUVendor(value string) string {
return "NVIDIA"
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
return "AMD"
+ case strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu"):
+ return "Virtio"
default:
return "Unknown"
}
@@ -1375,6 +1695,9 @@ func detectGPUVendor(value string) string {
func detectGPUType(value string) string {
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") {
return "integrated"
}
@@ -1394,9 +1717,10 @@ func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe {
devKVM := fileExists("/dev/kvm")
nested, detail := detectNestedVirtualization()
lxcOK := envCheckOK(env, "lxc-create")
+ kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
probe := HostRuntimeProbe{
LXCAvailable: lxcOK,
- KVMAvailable: devKVM && envCheckOK(env, "virsh"),
+ KVMAvailable: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()),
DevKVM: devKVM,
NestedVirtualization: nested,
NestedDetail: detail,
@@ -1446,6 +1770,7 @@ func detectSystemProbe() HostSystemProbe {
}
func detectHostEnvironment() []HostEnvCheck {
+ qemuCheck := commandCheck(kvmQEMUCheckKey(), "QEMU/KVM 虚拟机", false, kvmQEMUCommand(), "")
checks := []HostEnvCheck{
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
@@ -1454,7 +1779,7 @@ func detectHostEnvironment() []HostEnvCheck {
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
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("xorriso", "ISO 备用工具", false, "xorriso", ""),
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
@@ -1467,6 +1792,19 @@ func detectHostEnvironment() []HostEnvCheck {
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 {
ok := commandExists(cmd)
detail := "missing"
diff --git a/backend/internal/api/host_test.go b/backend/internal/api/host_test.go
index 6d2e185..f0274f4 100644
--- a/backend/internal/api/host_test.go
+++ b/backend/internal/api/host_test.go
@@ -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)
+ }
+ }
+}
diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go
index 99335a0..8f8f0b5 100644
--- a/backend/internal/api/images.go
+++ b/backend/internal/api/images.go
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "runtime"
"sync"
"time"
@@ -227,9 +228,14 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
enabledSet := getEnabledImageSet()
cleanupOldImageDownloadErrors()
+ kvmAvailable := hostKVMAvailable()
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 {
dl := imageDownloadInfo(t.ID)
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
@@ -252,7 +258,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
SizeBytes: size,
})
}
- for _, t := range kvm.GetImages() {
+ for _, t := range kvmImages {
dl := imageDownloadInfo(t.ID)
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := ""
@@ -309,6 +315,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
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 {
ensureImageEnabled(image.ID)
clearImageDownload(image.ID)
@@ -534,6 +544,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
result := make([]map[string]string, 0)
if runtime == config.VirtualizationKVM {
+ if !hostKVMAvailable() {
+ jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
+ return
+ }
for _, t := range kvm.GetImages() {
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
result = append(result, map[string]string{
@@ -563,6 +577,9 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
runtime = runtimeFromRequest(runtime)
if runtime == config.VirtualizationKVM {
+ if !hostKVMAvailable() {
+ return false
+ }
image := kvm.FindImage(templateID)
if image == nil {
return false
@@ -579,6 +596,13 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
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) {
// 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.
diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go
index 5bead1e..f583455 100644
--- a/backend/internal/cli/cli.go
+++ b/backend/internal/cli/cli.go
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "runtime"
"sort"
"strconv"
"strings"
@@ -125,32 +126,34 @@ var cliTranslations = map[string]string{
"检查仓库": "Checking repository",
"检查 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.",
- "最新版本": "Latest version",
- "发布页面": "Release page",
- "最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.",
- "当前已经是最新版本。": "The current version is already the latest.",
- "是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
- "输入 upgrade 开始升级": "Type upgrade to start upgrade",
- "已取消。": "Cancelled.",
- "升级失败": "Upgrade failed",
- "升级完成": "Upgrade completed",
- "原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
- "GitHub API 返回": "GitHub API returned",
- "GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
- "GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
- "GitHub releases/latest 返回": "GitHub releases/latest returned",
- "无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
- "正在下载升级包...": "Downloading upgrade package...",
- "正在解压升级包...": "Extracting upgrade package...",
- "解压失败": "Extraction failed",
- "备份旧二进制失败": "Failed to back up old binary",
- "旧版本已备份": "Old version backed up",
- "正在替换二进制...": "Replacing binary...",
- "停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
- "二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
- "下载失败,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.",
+ "最新版本": "Latest version",
+ "发布页面": "Release page",
+ "当前架构不支持自动升级": "Automatic upgrade is not supported on the current architecture",
+ "最新 Release 没有找到": "The latest release does not contain",
+ "无法自动升级。": "automatic upgrade is unavailable.",
+ "当前已经是最新版本。": "The current version is already the latest.",
+ "是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
+ "输入 upgrade 开始升级": "Type upgrade to start upgrade",
+ "已取消。": "Cancelled.",
+ "升级失败": "Upgrade failed",
+ "升级完成": "Upgrade completed",
+ "原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
+ "GitHub API 返回": "GitHub API returned",
+ "GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
+ "GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
+ "GitHub releases/latest 返回": "GitHub releases/latest returned",
+ "无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
+ "正在下载升级包...": "Downloading upgrade package...",
+ "正在解压升级包...": "Extracting upgrade package...",
+ "解压失败": "Extraction failed",
+ "备份旧二进制失败": "Failed to back up old binary",
+ "旧版本已备份": "Old version backed up",
+ "正在替换二进制...": "Replacing binary...",
+ "停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
+ "二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
+ "下载失败,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.",
"导入失败": "Import failed",
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
@@ -557,11 +560,16 @@ func cliUpgradeSystem(reader *bufio.Reader) {
if repo == "" {
repo = version.Repo
}
+ assetName, err := releaseArchiveAssetName(runtime.GOARCH)
+ if err != nil {
+ cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH)
+ return
+ }
current := version.Current()
cliPrintf("当前版本: %s\n", current)
cliPrintf("检查仓库: https://github.com/%s\n", repo)
- release, err := fetchLatestRelease(repo)
+ release, err := fetchLatestRelease(repo, assetName)
if err != nil {
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
return
@@ -576,9 +584,9 @@ func cliUpgradeSystem(reader *bufio.Reader) {
cliPrintf("发布页面: %s\n", release.HTMLURL)
}
- assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
+ assetURL := findReleaseAsset(release, assetName)
if assetURL == "" {
- cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
+ cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName)
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)
return
}
@@ -605,7 +613,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
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)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
@@ -617,7 +625,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
- if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
+ if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
return fallback, nil
}
return nil, err
@@ -627,7 +635,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
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 {
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
} else {
@@ -645,7 +653,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
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)
if err != nil {
return nil, err
@@ -667,7 +675,6 @@ func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
}
- const assetName = "clicd-linux-amd64.tar.gz"
return &githubRelease{
TagName: 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 {
for _, asset := range release.Assets {
if asset.Name == name && asset.BrowserDownloadURL != "" {
@@ -717,14 +733,14 @@ func findReleaseAsset(release *githubRelease, name string) string {
return ""
}
-func upgradeFromReleaseAsset(assetURL, latest string) error {
+func upgradeFromReleaseAsset(assetURL, latest, assetName string) error {
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
- archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
+ archivePath := filepath.Join(tmpDir, assetName)
cliPrintln("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil {
return err
diff --git a/backend/internal/cli/cli_test.go b/backend/internal/cli/cli_test.go
index 3c0cffa..1739ca1 100644
--- a/backend/internal/cli/cli_test.go
+++ b/backend/internal/cli/cli_test.go
@@ -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) {
unsafeNames := []string{
"../clicd",
diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go
index 74c7d06..21aa5a9 100644
--- a/backend/internal/kvm/kvm.go
+++ b/backend/internal/kvm/kvm.go
@@ -20,6 +20,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
+ "runtime"
"sort"
"strconv"
"strings"
@@ -1538,7 +1539,13 @@ func (m *Manager) validateHost(skipCloudInit bool) error {
return err
}
}
+ if err := requireCommand(kvmEmulatorCommand()); err != nil {
+ return err
+ }
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 {
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, ", "))
}
+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 {
// Ensure libvirtd is running
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 = ""
input = "\n\t "
}
+ osAttrs := ""
+ features := ""
+ if runtime.GOARCH == "arm64" {
+ osAttrs = " firmware='efi'"
+ features = ""
+ }
+ seedDisk := fmt.Sprintf(`
+
+
+
+
+ `, xmlEscape(seedPath))
+ if runtime.GOARCH == "arm64" {
+ seedDisk = fmt.Sprintf(`
+
+
+
+
+ `, xmlEscape(seedPath))
+ }
return fmt.Sprintf(`
%s
%s
@@ -2193,29 +2254,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
%d
%d
2048
-
- hvm
+
+ hvm
-
+ %s
destroy
restart
restart
- /usr/bin/qemu-system-x86_64
+ %s
%s
-
-
-
-
-
-
+ %s
@@ -2232,7 +2288,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
%s
%s
-`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
+`, 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 {
diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go
index 04f971e..0f85bf9 100644
--- a/backend/internal/kvm/templates.go
+++ b/backend/internal/kvm/templates.go
@@ -2,6 +2,7 @@ package kvm
import (
"path/filepath"
+ "runtime"
)
type Image struct {
@@ -16,6 +17,15 @@ type Image struct {
}
func GetImages() []Image {
+ switch runtime.GOARCH {
+ case "arm64":
+ return arm64Images()
+ default:
+ return amd64Images()
+ }
+}
+
+func amd64Images() []Image {
return []Image{
{
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 {
for _, image := range GetImages() {
if image.ID == id {
diff --git a/backend/internal/lxc/templates.go b/backend/internal/lxc/templates.go
index c608a95..ae0cb03 100644
--- a/backend/internal/lxc/templates.go
+++ b/backend/internal/lxc/templates.go
@@ -1,5 +1,7 @@
package lxc
+import "runtime"
+
// Template represents an LXC image template
type Template struct {
ID string `json:"id"`
@@ -13,55 +15,65 @@ type Template struct {
// GetTemplates returns available LXC image templates (only verified working ones)
func GetTemplates() []Template {
+ arch := defaultTemplateArch()
return []Template{
{
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
- Distro: "ubuntu", Release: "noble", Arch: "amd64",
+ Distro: "ubuntu", Release: "noble", Arch: arch,
Description: "Ubuntu 24.04 LTS",
},
{
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
- Distro: "ubuntu", Release: "jammy", Arch: "amd64",
+ Distro: "ubuntu", Release: "jammy", Arch: arch,
Description: "Ubuntu 22.04 LTS",
},
{
ID: "debian-bookworm", Name: "Debian 12",
- Distro: "debian", Release: "bookworm", Arch: "amd64",
+ Distro: "debian", Release: "bookworm", Arch: arch,
Description: "Debian 12 (Bookworm)",
},
{
ID: "debian-bullseye", Name: "Debian 11",
- Distro: "debian", Release: "bullseye", Arch: "amd64",
+ Distro: "debian", Release: "bullseye", Arch: arch,
Description: "Debian 11 (Bullseye)",
},
{
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",
},
{
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",
},
{
ID: "archlinux-current", Name: "Arch Linux",
- Distro: "archlinux", Release: "current", Arch: "amd64",
+ Distro: "archlinux", Release: "current", Arch: arch,
Description: "Arch Linux (Rolling)",
},
{
ID: "fedora-44", Name: "Fedora 44",
- Distro: "fedora", Release: "44", Arch: "amd64",
+ Distro: "fedora", Release: "44", Arch: arch,
Description: "Fedora 44",
},
{
ID: "rockylinux-10", Name: "Rocky Linux 10",
- Distro: "rockylinux", Release: "10", Arch: "amd64",
+ Distro: "rockylinux", Release: "10", Arch: arch,
Description: "Rocky Linux 10",
},
}
}
+func defaultTemplateArch() string {
+ switch runtime.GOARCH {
+ case "arm64":
+ return "arm64"
+ default:
+ return "amd64"
+ }
+}
+
// FindTemplate finds a template by ID
func FindTemplate(id string) *Template {
templates := GetTemplates()
diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep
index 30259b2..e69de29 100644
--- a/backend/internal/server/web/.gitkeep
+++ b/backend/internal/server/web/.gitkeep
@@ -1 +0,0 @@
-
diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go
index 2465c5f..bc37028 100644
--- a/backend/internal/version/version.go
+++ b/backend/internal/version/version.go
@@ -1,7 +1,7 @@
package version
var (
- Version = "1.1.21"
+ Version = "1.1.22"
Repo = "MengMengCode/CLICD"
)
diff --git a/build.sh b/build.sh
index 381bd7e..46236c8 100644
--- a/build.sh
+++ b/build.sh
@@ -6,6 +6,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
+DIST_DIR="$SCRIPT_DIR/dist"
FRONTEND_DIR="$SCRIPT_DIR/frontend"
BACKEND_DIR="$SCRIPT_DIR/backend"
WEB_DIR="$SCRIPT_DIR/web"
@@ -17,9 +18,11 @@ echo "====================================="
# Clean previous build
rm -rf "$BUILD_DIR"
+rm -rf "$DIST_DIR"
rm -rf "$WEB_DIR"
rm -rf "$EMBED_WEB_DIR"
mkdir -p "$BUILD_DIR"
+mkdir -p "$DIST_DIR"
mkdir -p "$WEB_DIR"
mkdir -p "$EMBED_WEB_DIR"
touch "$EMBED_WEB_DIR/.gitkeep"
@@ -51,9 +54,26 @@ cd "$BACKEND_DIR"
go mod tidy
go mod download
-# Build for Linux amd64
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"
@@ -62,7 +82,20 @@ echo ""
echo "[3/3] Packaging..."
cp -r "$WEB_DIR" "$BUILD_DIR/web"
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 "====================================="
@@ -70,6 +103,11 @@ echo " Build Complete!"
echo "====================================="
echo " Output: $BUILD_DIR/clicd"
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 " To deploy:"
echo " 1. Copy build/ directory to server"
diff --git a/docs/developer/build.md b/docs/developer/build.md
index 8b1f0fa..d1cc8e0 100644
--- a/docs/developer/build.md
+++ b/docs/developer/build.md
@@ -30,6 +30,25 @@ bash build.sh
该脚本用于串联前端构建、静态资源同步和 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
diff --git a/docs/developer/release.md b/docs/developer/release.md
index 761ebf1..b9f629f 100644
--- a/docs/developer/release.md
+++ b/docs/developer/release.md
@@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语
## Release 产物
-安装脚本会优先下载 Linux AMD64 产物:
+安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物:
```text
clicd-linux-amd64.tar.gz
+clicd-linux-arm64.tar.gz
```
在部分场景中也会尝试下载单独二进制:
```text
clicd-linux-amd64
+clicd-linux-arm64
```
## 安装脚本行为
diff --git a/docs/en/developer/build.md b/docs/en/developer/build.md
index 1b1a788..96285f9 100644
--- a/docs/en/developer/build.md
+++ b/docs/en/developer/build.md
@@ -30,6 +30,25 @@ bash build.sh
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
```bash
diff --git a/docs/en/developer/release.md b/docs/en/developer/release.md
index 8e16543..2150d8a 100644
--- a/docs/en/developer/release.md
+++ b/docs/en/developer/release.md
@@ -12,16 +12,18 @@ Check the version in:
## 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
clicd-linux-amd64.tar.gz
+clicd-linux-arm64.tar.gz
```
In some cases, it may also try the standalone binary:
```text
clicd-linux-amd64
+clicd-linux-arm64
```
## Installer Behavior
diff --git a/docs/en/guide/installation.md b/docs/en/guide/installation.md
index 1f66d62..5c8371a 100644
--- a/docs/en/guide/installation.md
+++ b/docs/en/guide/installation.md
@@ -4,7 +4,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
## Requirements
-- Linux x86_64 host.
+- Linux x86_64/amd64 or ARM64/aarch64 host.
- Root privileges.
- systemd.
- 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
```
-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
diff --git a/docs/en/guide/introduction.md b/docs/en/guide/introduction.md
index f57fc89..e109107 100644
--- a/docs/en/guide/introduction.md
+++ b/docs/en/guide/introduction.md
@@ -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.
- 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.
diff --git a/docs/en/operations/faq.md b/docs/en/operations/faq.md
index f47fa65..1ce303f 100644
--- a/docs/en/operations/faq.md
+++ b/docs/en/operations/faq.md
@@ -2,7 +2,7 @@
## 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?
diff --git a/docs/guide/installation.md b/docs/guide/installation.md
index 47f5d2b..dd446d5 100644
--- a/docs/guide/installation.md
+++ b/docs/guide/installation.md
@@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
## 环境要求
-- Linux x86_64 宿主机。
+- Linux x86_64/amd64 或 ARM64/aarch64 宿主机。
- root 权限。
- systemd。
- 网络可访问 GitHub Release 下载地址。
@@ -17,7 +17,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
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`。
## 安装指定版本
diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md
index 4b071b5..1a420cb 100644
--- a/docs/guide/introduction.md
+++ b/docs/guide/introduction.md
@@ -26,4 +26,4 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
-- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。
+- 发布:GitHub Actions 构建 Linux AMD64/ARM64 release 产物,安装脚本默认拉取最新 Release。
diff --git a/docs/operations/faq.md b/docs/operations/faq.md
index 7af1677..54bcf76 100644
--- a/docs/operations/faq.md
+++ b/docs/operations/faq.md
@@ -2,7 +2,7 @@
## 安装脚本默认安装哪个版本?
-默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
+默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。
## 可以固定安装某个版本吗?
diff --git a/frontend/package.json b/frontend/package.json
index 1703379..d06770a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
- "version": "1.1.21",
+ "version": "1.1.22",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx
index fc4fcb8..12f5f31 100644
--- a/frontend/src/components/CreateContainerModal.tsx
+++ b/frontend/src/components/CreateContainerModal.tsx
@@ -98,6 +98,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const manualIPv4s = form.public_ipv4s || []
const maxVCPU = hostInfo?.cpu.cores || 64
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 resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const natEnabled = form.assign_nat !== false
@@ -241,8 +248,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
@@ -408,7 +421,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
{networkText.publicIPv6}
- {ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
+ {ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)}
@@ -762,7 +775,7 @@ const createNetworkText = {
zh: {
publicIPv4: '公网 IPv4',
noAllocatableIPv4: '未检测到可分配公网 IPv4',
- publicIPv6: '公网 IPv6',
+ publicIPv6: '可分配 IPv6 前缀',
use: '使用',
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
publicNAT: '公网 NAT',
@@ -771,7 +784,7 @@ const createNetworkText = {
en: {
publicIPv4: 'Public IPv4',
noAllocatableIPv4: 'No allocatable public IPv4 detected',
- publicIPv6: 'Public IPv6',
+ publicIPv6: 'Allocatable IPv6 Prefix',
use: 'Use',
checkingIPv6Prefix: 'Checking IPv6 prefix...',
publicNAT: 'Public NAT',
@@ -779,6 +792,21 @@ const createNetworkText = {
},
} 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) {
return language === 'en'
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx
index 3815218..8dc21fc 100644
--- a/frontend/src/pages/Containers.tsx
+++ b/frontend/src/pages/Containers.tsx
@@ -395,10 +395,8 @@ export default function Containers() {
const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name]
- const isKVM = (container.virtualization || 'lxc') === 'kvm'
-
const cpuPct = isRunning
- ? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
+ ? clamp((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))
: 0
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
? usage.memory_total_bytes
diff --git a/frontend/src/pages/HostReport.tsx b/frontend/src/pages/HostReport.tsx
index 7ddd84b..0dc72dd 100644
--- a/frontend/src/pages/HostReport.tsx
+++ b/frontend/src/pages/HostReport.tsx
@@ -205,7 +205,7 @@ const hostReportText = {
ipv4Address: 'IPv4 地址',
ipv4Prefix: 'IPv4 段',
ipv6Address: 'IPv6 地址',
- ipv6Prefix: 'IPv6 段',
+ ipv6Prefix: '可分配 IPv6 前缀',
gateway: '网关',
memoryModules: '内存条',
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
@@ -277,7 +277,7 @@ const hostReportText = {
ipv4Address: 'IPv4 Addresses',
ipv4Prefix: 'IPv4 Prefixes',
ipv6Address: 'IPv6 Addresses',
- ipv6Prefix: 'IPv6 Prefixes',
+ ipv6Prefix: 'Allocatable IPv6 Prefixes',
gateway: 'Gateway',
memoryModules: 'Memory Modules',
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) {
+ if (value === 'virtual') return language === 'en' ? 'Virtual' : '虚拟'
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
return value || '-'
diff --git a/frontend/src/pages/ImageManagement.tsx b/frontend/src/pages/ImageManagement.tsx
index a564334..eaa0bb1 100644
--- a/frontend/src/pages/ImageManagement.tsx
+++ b/frontend/src/pages/ImageManagement.tsx
@@ -148,17 +148,19 @@ export default function ImageManagement() {
onToggle={handleToggle}
/>
- img.downloaded).length}
- totalCount={kvmImages.length}
- onDownload={handleDownload}
- onCancelDownload={handleCancelDownload}
- onDelete={handleDelete}
- onToggle={handleToggle}
- />
+ {kvmImages.length > 0 && (
+ img.downloaded).length}
+ totalCount={kvmImages.length}
+ onDownload={handleDownload}
+ onCancelDownload={handleCancelDownload}
+ onDelete={handleDelete}
+ onToggle={handleToggle}
+ />
+ )}
)
}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 03765d2..9ea93dc 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -128,7 +128,7 @@ export default function Login() {
- CLICD v1.1.21
+ CLICD v1.1.22
)
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 1fc2173..6d8e4be 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -235,6 +235,14 @@ export interface HostInfo {
}
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: 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 {
diff --git a/install.sh b/install.sh
index 9224d2c..36bffbd 100644
--- a/install.sh
+++ b/install.sh
@@ -3,7 +3,6 @@ set -eu
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
-ASSET="clicd-linux-amd64.tar.gz"
ACTION="${1:-install}"
ACTION_CONFIRM="${2:-}"
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.$$}"
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/LXC;KVM 功能当前支持 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() {
lang="$1"
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
@@ -286,14 +359,8 @@ run_step() {
}
check_os_compatibility() {
- log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)"
- case "$(uname -m 2>/dev/null || echo unknown)" in
- x86_64|amd64)
- ;;
- *)
- die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)。"
- ;;
- esac
+ log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=${HOST_ARCH_RAW} CLICD_ARCH=${CLICD_ARCH_NORMALIZED:-unsupported}"
+ [ -n "$CLICD_ARCH_NORMALIZED" ] || die "当前安装包支持 x86_64/amd64 和 aarch64/arm64,当前架构:${HOST_ARCH_RAW}。"
if ! is_systemd && ! is_openrc; then
die "未检测到 systemd 或 OpenRC,无法安装服务。"
fi
@@ -476,7 +543,16 @@ remove_clicd_lxc_image_cache() {
"centos 9-Stream amd64" \
"archlinux current 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
set -- $image
distro="$1"
@@ -950,15 +1026,21 @@ install_apk() {
iproute2 \
iptables \
dnsmasq \
- dbus \
- qemu-system-x86_64 \
+ dbus
+
+ if kvm_supported_arch; then
+ apk add --no-cache \
+ "$(qemu_system_package_apk)" \
qemu-img \
libvirt \
libvirt-daemon \
libvirt-client \
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"
done
}
@@ -985,18 +1067,39 @@ install_apt() {
quota \
e2fsprogs \
xfsprogs \
- dnsmasq-base \
- qemu-kvm \
- qemu-system-x86 \
- qemu-utils \
- libvirt-daemon-system \
- libvirt-clients \
- cloud-image-utils \
- genisoimage \
- xorriso \
- smartmontools \
- virtinst \
- ovmf
+ dnsmasq-base
+
+ if kvm_supported_arch; then
+ if [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]; then
+ apt-get install -y \
+ "$(qemu_system_package_apt)" \
+ qemu-utils \
+ libvirt-daemon-system \
+ libvirt-clients \
+ cloud-image-utils \
+ genisoimage \
+ 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() {
@@ -1032,8 +1135,11 @@ install_dnf() {
quota \
e2fsprogs \
xfsprogs \
- dnsmasq \
- qemu-kvm \
+ dnsmasq
+
+ if kvm_supported_arch; then
+ dnf install -y \
+ "$(qemu_system_package_rpm)" \
qemu-img \
libvirt \
libvirt-daemon-kvm \
@@ -1041,8 +1147,12 @@ install_dnf() {
virt-install \
cloud-utils \
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"
done
}
@@ -1067,8 +1177,11 @@ install_yum() {
quota \
e2fsprogs \
xfsprogs \
- dnsmasq \
- qemu-kvm \
+ dnsmasq
+
+ if kvm_supported_arch; then
+ yum install -y \
+ "$(qemu_system_package_rpm)" \
qemu-img \
libvirt \
libvirt-daemon-kvm \
@@ -1076,8 +1189,12 @@ install_yum() {
virt-install \
cloud-utils \
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"
done
}
@@ -1117,14 +1234,19 @@ install_dependencies() {
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
- has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
- has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
- has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
- if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
- die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。"
- fi
- if [ ! -e /dev/kvm ]; then
- warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
+ if kvm_supported_arch; then
+ has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
+ has_cmd "$(qemu_emulator_cmd)" || die "依赖安装后仍未找到 $(qemu_emulator_cmd),请检查 QEMU 安装。"
+ has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
+ has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
+ if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
+ die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。"
+ fi
+ if [ ! -e /dev/kvm ]; then
+ warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
+ fi
+ else
+ warn_kvm_unsupported_arch
fi
}
@@ -1384,7 +1506,7 @@ download_release_if_needed() {
if [ "$archive_ok" = "1" ]; then
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
else
- binary_asset="clicd-linux-amd64"
+ binary_asset="$BINARY_ASSET"
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
else
@@ -1402,9 +1524,9 @@ download_release_if_needed() {
[ -n "$url" ] || continue
log "Trying release binary: $url"
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
- mkdir -p "$tmp_dir/clicd-linux-amd64"
- cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd"
- chmod +x "$tmp_dir/clicd-linux-amd64/clicd"
+ mkdir -p "$tmp_dir/$ASSET_DIR"
+ cp "$binary_path" "$tmp_dir/$ASSET_DIR/clicd"
+ chmod +x "$tmp_dir/$ASSET_DIR/clicd"
binary_ok=1
break
fi
@@ -1414,8 +1536,8 @@ download_release_if_needed() {
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
fi
- [ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory"
- [ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
+ [ -d "$tmp_dir/$ASSET_DIR" ] || die "Release package layout is invalid: missing $ASSET_DIR directory"
+ [ -f "$tmp_dir/$ASSET_DIR/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
}
install_binary() {
@@ -1430,8 +1552,8 @@ install_binary() {
download_dir=""
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
- if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then
- bin_src="$download_dir/clicd-linux-amd64/clicd"
+ if [ -n "$download_dir" ] && [ -f "$download_dir/$ASSET_DIR/clicd" ]; then
+ bin_src="$download_dir/$ASSET_DIR/clicd"
fi
fi
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"