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
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/*
- name: Publish GitHub Release
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
env:
GH_TOKEN: ${{ github.token }}
run: |
+2
View File
@@ -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
+355 -17
View File
@@ -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"
+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/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.
+28 -12
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
@@ -127,7 +128,9 @@ var cliTranslations = map[string]string{
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
"最新版本": "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.",
"当前架构不支持自动升级": "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",
@@ -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
+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) {
unsafeNames := []string{
"../clicd",
+67 -11
View File
@@ -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 = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
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'>
<name>%s</name>
%s
@@ -2193,29 +2254,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<currentMemory unit='MiB'>%d</currentMemory>
<vcpu placement='static' current='%d'>%d</vcpu>
<cputune><shares>2048</shares></cputune>
<os>
<type arch='x86_64' machine='pc'>hvm</type>
<os%s>
<type arch='%s' machine='%s'>hvm</type>
<boot dev='hd'/>
</os>
<features><acpi/><apic/></features>
%s
<cpu mode='host-passthrough' check='none'/>
<clock offset='utc'/>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>restart</on_crash>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<emulator>%s</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2' cache='none'/>
<source file='%s'/>
<target dev='vda' bus='virtio'/>%s
</disk>
<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='hdb' bus='ide'/>
<readonly/>
</disk>
%s
<interface type='network'>
<mac address='%s'/>
<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
%s
</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 {
+57
View File
@@ -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 {
+21 -9
View File
@@ -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()
-1
View File
@@ -1 +0,0 @@
+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.21"
Version = "1.1.22"
Repo = "MengMengCode/CLICD"
)
+41 -3
View File
@@ -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"
+19
View File
@@ -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
+3 -1
View File
@@ -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
```
## 安装脚本行为
+19
View File
@@ -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
+3 -1
View File
@@ -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
+2 -2
View File
@@ -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
+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.
- 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?
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?
+2 -2
View File
@@ -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`
## 安装指定版本
+1 -1
View File
@@ -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。
+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",
"private": true,
"version": "1.1.21",
"version": "1.1.22",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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
</button>
<button
type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
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'}`}
disabled={!kvmAvailable}
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
</button>
@@ -408,7 +421,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
<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>
</label>
@@ -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`
+1 -3
View File
@@ -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
+3 -2
View File
@@ -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 || '-'
+2
View File
@@ -148,6 +148,7 @@ export default function ImageManagement() {
onToggle={handleToggle}
/>
{kvmImages.length > 0 && (
<ImageTable
title="KVM 虚拟机镜像"
images={kvmImages}
@@ -159,6 +160,7 @@ export default function ImageManagement() {
onDelete={handleDelete}
onToggle={handleToggle}
/>
)}
</div>
)
}
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form>
</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>
)
+8
View File
@@ -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 {
+153 -31
View File
@@ -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/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() {
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,9 +1067,12 @@ install_apt() {
quota \
e2fsprogs \
xfsprogs \
dnsmasq-base \
qemu-kvm \
qemu-system-x86 \
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 \
@@ -996,7 +1081,25 @@ install_apt() {
xorriso \
smartmontools \
virtinst \
ovmf
"$(qemu_efi_package_apt)"
else
apt-get install -y \
qemu-kvm \
"$(qemu_system_package_apt)" \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
smartmontools \
virtinst \
"$(qemu_efi_package_apt)"
fi
else
warn_kvm_unsupported_arch
apt-get install -y qemu-utils genisoimage xorriso smartmontools >/dev/null 2>&1 || true
fi
}
enable_el_repos() {
@@ -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,7 +1234,9 @@ install_dependencies() {
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
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
@@ -1126,6 +1245,9 @@ install_dependencies() {
if [ ! -e /dev/kvm ]; then
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
fi
else
warn_kvm_unsupported_arch
fi
}
configure_kernel_networking() {
@@ -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 二进制,安装无法继续。"