diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go index d9f9043..1f57cf7 100644 --- a/backend/internal/api/host.go +++ b/backend/internal/api/host.go @@ -1424,6 +1424,7 @@ func detectHostEnvironment() []HostEnvCheck { commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"), commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""), commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""), + certbotCheck(), } checks = append(checks, HostEnvCheck{Key: "dev-kvm", Label: "/dev/kvm 硬件虚拟化", OK: fileExists("/dev/kvm"), Required: false, Detail: boolDetail(fileExists("/dev/kvm"))}) checks = append(checks, HostEnvCheck{Key: "ipv4-forward", Label: "IPv4 转发", OK: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward")) == "1", Required: true, Detail: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward"))}) @@ -1446,6 +1447,70 @@ func commandCheck(key, label string, required bool, cmd string, fallback string) return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail} } +func certbotCheck() HostEnvCheck { + check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"} + if !commandExists("certbot") { + return check + } + detail := strings.TrimSpace(runCommandOutput(3*time.Second, "certbot", "--version")) + if detail == "" { + detail = strings.TrimSpace(runCommandOutput(3*time.Second, "sh", "-c", "certbot --version 2>&1 | head -n 1")) + } + if detail == "" { + detail = "installed, version unknown" + } + check.Detail = detail + version := extractCertbotVersion(detail) + check.OK = certbotVersionAtLeast(version, 5, 4) + if version == "" { + check.Detail = detail + " (version unknown, need >= 5.4)" + } else if !check.OK { + check.Detail = detail + " (need >= 5.4)" + } + return check +} + +func extractCertbotVersion(output string) string { + for _, field := range strings.Fields(output) { + field = strings.Trim(field, "vV,;:()[]{}") + if field == "" || field[0] < '0' || field[0] > '9' { + continue + } + return field + } + return "" +} + +func certbotVersionAtLeast(version string, minMajor, minMinor int) bool { + parts := strings.Split(version, ".") + if len(parts) < 2 { + return false + } + major, err := strconv.Atoi(numericPrefix(parts[0])) + if err != nil { + return false + } + minor, err := strconv.Atoi(numericPrefix(parts[1])) + if err != nil { + return false + } + if major != minMajor { + return major > minMajor + } + return minor >= minMinor +} + +func numericPrefix(value string) string { + var b strings.Builder + for _, r := range value { + if r < '0' || r > '9' { + break + } + b.WriteRune(r) + } + return b.String() +} + func envCheckOK(checks []HostEnvCheck, key string) bool { for _, check := range checks { if check.Key == key { diff --git a/backend/internal/api/host_test.go b/backend/internal/api/host_test.go new file mode 100644 index 0000000..6d2e185 --- /dev/null +++ b/backend/internal/api/host_test.go @@ -0,0 +1,43 @@ +package api + +import "testing" + +func TestExtractCertbotVersion(t *testing.T) { + tests := []struct { + output string + want string + }{ + {"certbot 5.4.0", "5.4.0"}, + {"certbot v5.10.1", "5.10.1"}, + {"certbot, version 4.9", "4.9"}, + {"installed", ""}, + } + + for _, tt := range tests { + if got := extractCertbotVersion(tt.output); got != tt.want { + t.Fatalf("extractCertbotVersion(%q) = %q, want %q", tt.output, got, tt.want) + } + } +} + +func TestCertbotVersionAtLeast54(t *testing.T) { + tests := []struct { + version string + want bool + }{ + {"5.4", true}, + {"5.4.0", true}, + {"5.10", true}, + {"6.0.0", true}, + {"5.3.9", false}, + {"4.99", false}, + {"5", false}, + {"", false}, + } + + for _, tt := range tests { + if got := certbotVersionAtLeast(tt.version, 5, 4); got != tt.want { + t.Fatalf("certbotVersionAtLeast(%q, 5, 4) = %v, want %v", tt.version, got, tt.want) + } + } +}