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
+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.
+53 -37
View File
@@ -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
+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"
)