mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
2046 lines
55 KiB
Go
2046 lines
55 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"clicd/internal/lxc"
|
|
)
|
|
|
|
type HostInfo struct {
|
|
CPU CpuInfo `json:"cpu"`
|
|
RAM MemoryInfo `json:"ram"`
|
|
Disk DiskInfo `json:"disk"`
|
|
Network NetworkInfo `json:"network"`
|
|
DiskIO DiskIOInfo `json:"disk_io"`
|
|
Load LoadInfo `json:"load"`
|
|
}
|
|
|
|
type HostProbeReport struct {
|
|
GeneratedAt string `json:"generated_at"`
|
|
Hostname string `json:"hostname"`
|
|
Kernel string `json:"kernel"`
|
|
OS string `json:"os"`
|
|
CPU HostCPUProbe `json:"cpu"`
|
|
Memory HostMemoryProbe `json:"memory"`
|
|
Disks []HostDiskProbe `json:"disks"`
|
|
NetworkInterfaces []HostNICProbe `json:"network_interfaces"`
|
|
PublicIPv4 []string `json:"public_ipv4"`
|
|
IPv4Addresses []HostIPProbe `json:"ipv4_addresses"`
|
|
IPv4Prefixes []HostIPv4PrefixProbe `json:"ipv4_prefixes"`
|
|
IPv6Addresses []HostIPProbe `json:"ipv6_addresses"`
|
|
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
|
Gateways []HostGatewayProbe `json:"gateways"`
|
|
GPUs []HostGPUProbe `json:"gpus"`
|
|
Runtime HostRuntimeProbe `json:"runtime"`
|
|
System HostSystemProbe `json:"system"`
|
|
Environment []HostEnvCheck `json:"environment"`
|
|
}
|
|
|
|
type HostCPUProbe struct {
|
|
Model string `json:"model"`
|
|
Cores int `json:"cores"`
|
|
Threads int `json:"threads"`
|
|
Architecture string `json:"architecture"`
|
|
Flags []string `json:"flags"`
|
|
HasIntegratedGPU bool `json:"has_integrated_gpu"`
|
|
Virtualization bool `json:"virtualization"`
|
|
VirtualizationKey string `json:"virtualization_key"`
|
|
}
|
|
|
|
type HostMemoryProbe struct {
|
|
TotalMB int64 `json:"total_mb"`
|
|
UsedMB int64 `json:"used_mb"`
|
|
FreeMB int64 `json:"free_mb"`
|
|
Modules []HostMemoryModule `json:"modules"`
|
|
}
|
|
|
|
type HostMemoryModule struct {
|
|
Locator string `json:"locator"`
|
|
Size string `json:"size"`
|
|
Type string `json:"type"`
|
|
Speed string `json:"speed"`
|
|
Manufacturer string `json:"manufacturer"`
|
|
PartNumber string `json:"part_number"`
|
|
SerialNumber string `json:"serial_number"`
|
|
}
|
|
|
|
type HostDiskProbe struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Model string `json:"model"`
|
|
Serial string `json:"serial"`
|
|
SizeBytes uint64 `json:"size_bytes"`
|
|
Type string `json:"type"`
|
|
Virtual bool `json:"virtual"`
|
|
Rotational bool `json:"rotational"`
|
|
Mountpoints []string `json:"mountpoints"`
|
|
Health string `json:"health"`
|
|
HealthDetail string `json:"health_detail"`
|
|
SMART HostDiskSMARTProbe `json:"smart"`
|
|
}
|
|
|
|
type HostDiskSMARTProbe struct {
|
|
Available bool `json:"available"`
|
|
LifeUsedPercent *int `json:"life_used_percent,omitempty"`
|
|
PowerOnHours int64 `json:"power_on_hours,omitempty"`
|
|
PowerCycleCount int64 `json:"power_cycle_count,omitempty"`
|
|
ReadDataBytes uint64 `json:"read_data_bytes,omitempty"`
|
|
WrittenDataBytes uint64 `json:"written_data_bytes,omitempty"`
|
|
ReadCommands uint64 `json:"read_commands,omitempty"`
|
|
WriteCommands uint64 `json:"write_commands,omitempty"`
|
|
WearLevelingCount string `json:"wear_leveling_count,omitempty"`
|
|
EraseCount string `json:"erase_count,omitempty"`
|
|
MediaErrors uint64 `json:"media_errors,omitempty"`
|
|
}
|
|
|
|
type HostNICProbe struct {
|
|
Name string `json:"name"`
|
|
MAC string `json:"mac"`
|
|
State string `json:"state"`
|
|
SpeedMbps int `json:"speed_mbps"`
|
|
Driver string `json:"driver"`
|
|
Model string `json:"model"`
|
|
IPv4 []HostIPProbe `json:"ipv4"`
|
|
IPv6 []HostIPProbe `json:"ipv6"`
|
|
}
|
|
|
|
type HostIPProbe struct {
|
|
Interface string `json:"interface"`
|
|
Address string `json:"address"`
|
|
PrefixLen int `json:"prefix_len"`
|
|
Scope string `json:"scope"`
|
|
Gateway string `json:"gateway,omitempty"`
|
|
}
|
|
|
|
type HostIPv4PrefixProbe struct {
|
|
Interface string `json:"interface"`
|
|
Address string `json:"address"`
|
|
Prefix string `json:"prefix"`
|
|
PrefixLen int `json:"prefix_len"`
|
|
SubnetMask string `json:"subnet_mask"`
|
|
Gateway string `json:"gateway"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
type HostGatewayProbe struct {
|
|
Family string `json:"family"`
|
|
Interface string `json:"interface"`
|
|
Gateway string `json:"gateway"`
|
|
}
|
|
|
|
type HostGPUProbe struct {
|
|
Name string `json:"name"`
|
|
Vendor string `json:"vendor"`
|
|
Driver string `json:"driver"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type HostRuntimeProbe struct {
|
|
LXCAvailable bool `json:"lxc_available"`
|
|
KVMAvailable bool `json:"kvm_available"`
|
|
DevKVM bool `json:"dev_kvm"`
|
|
NestedVirtualization bool `json:"nested_virtualization"`
|
|
NestedDetail string `json:"nested_detail"`
|
|
SupportMode string `json:"support_mode"`
|
|
}
|
|
|
|
type HostSystemProbe struct {
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
UptimeText string `json:"uptime_text"`
|
|
ProcessCount int `json:"process_count"`
|
|
}
|
|
|
|
type HostEnvCheck struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
OK bool `json:"ok"`
|
|
Required bool `json:"required"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
|
|
type LoadInfo struct {
|
|
Load1 float64 `json:"load1"`
|
|
Load5 float64 `json:"load5"`
|
|
Load15 float64 `json:"load15"`
|
|
}
|
|
|
|
type CpuInfo struct {
|
|
Cores int `json:"cores"`
|
|
Usage float64 `json:"usage_pct"`
|
|
}
|
|
|
|
type MemoryInfo struct {
|
|
TotalMB int64 `json:"total_mb"`
|
|
UsedMB int64 `json:"used_mb"`
|
|
FreeMB int64 `json:"free_mb"`
|
|
}
|
|
|
|
type DiskInfo struct {
|
|
TotalGB float64 `json:"total_gb"`
|
|
UsedGB float64 `json:"used_gb"`
|
|
FreeGB float64 `json:"free_gb"`
|
|
}
|
|
|
|
type NetworkInfo struct {
|
|
RXBytes uint64 `json:"rx_bytes"`
|
|
TXBytes uint64 `json:"tx_bytes"`
|
|
RXBps float64 `json:"rx_bps"`
|
|
TXBps float64 `json:"tx_bps"`
|
|
PublicIPv4 string `json:"public_ipv4"`
|
|
PublicIPv4Interface string `json:"public_ipv4_interface"`
|
|
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
|
PublicIPv6 string `json:"public_ipv6"`
|
|
PublicIPv6Interface string `json:"public_ipv6_interface"`
|
|
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
|
}
|
|
|
|
type DiskIOInfo struct {
|
|
ReadBytes uint64 `json:"read_bytes"`
|
|
WriteBytes uint64 `json:"write_bytes"`
|
|
ReadBps float64 `json:"read_bps"`
|
|
WriteBps float64 `json:"write_bps"`
|
|
}
|
|
|
|
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
|
|
Idle uint64
|
|
}
|
|
|
|
type hostIOSample struct {
|
|
RXBytes uint64
|
|
TXBytes uint64
|
|
ReadBytes uint64
|
|
WriteBytes uint64
|
|
At int64
|
|
}
|
|
|
|
func getHostInfo() HostInfo {
|
|
info := HostInfo{
|
|
CPU: CpuInfo{Cores: runtime.NumCPU()},
|
|
}
|
|
|
|
info.RAM = getMemoryInfo()
|
|
info.Disk = getDiskInfo()
|
|
info.CPU.Usage = getCPUUsage()
|
|
info.Network, info.DiskIO = getHostRates()
|
|
info.Load = getLoadInfo()
|
|
return info
|
|
}
|
|
|
|
func getMemoryInfo() MemoryInfo {
|
|
f, err := os.Open("/proc/meminfo")
|
|
if err != nil {
|
|
return MemoryInfo{TotalMB: 0, UsedMB: 0, FreeMB: 0}
|
|
}
|
|
defer f.Close()
|
|
|
|
var total, available, free int64
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
val, _ := strconv.ParseInt(fields[1], 10, 64)
|
|
switch fields[0] {
|
|
case "MemTotal:":
|
|
total = val / 1024
|
|
case "MemAvailable:":
|
|
available = val / 1024
|
|
case "MemFree:":
|
|
free = val / 1024
|
|
}
|
|
}
|
|
|
|
used := total - available
|
|
if available == 0 {
|
|
used = total - free
|
|
}
|
|
|
|
return MemoryInfo{
|
|
TotalMB: total,
|
|
UsedMB: used,
|
|
FreeMB: available,
|
|
}
|
|
}
|
|
|
|
func getDiskInfo() DiskInfo {
|
|
if info, ok := getRootDiskInfo(); ok {
|
|
return info
|
|
}
|
|
|
|
{
|
|
// Try command-based fallback
|
|
cmd := exec.Command("df", "-BG", "/")
|
|
output, err := cmd.Output()
|
|
if err == nil {
|
|
lines := strings.Split(string(output), "\n")
|
|
if len(lines) >= 2 {
|
|
fields := strings.Fields(lines[1])
|
|
if len(fields) >= 4 {
|
|
total, _ := parseSizeGBf(fields[1])
|
|
used, _ := parseSizeGBf(fields[2])
|
|
free, _ := parseSizeGBf(fields[3])
|
|
return DiskInfo{TotalGB: total, UsedGB: used, FreeGB: free}
|
|
}
|
|
}
|
|
}
|
|
return DiskInfo{}
|
|
}
|
|
}
|
|
|
|
func getCPUUsage() float64 {
|
|
current, err := readCPUTimes()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
hostCPUMu.Lock()
|
|
defer hostCPUMu.Unlock()
|
|
|
|
if lastHostCPU.Total == 0 {
|
|
lastHostCPU = current
|
|
return 0
|
|
}
|
|
|
|
totalDelta := current.Total - lastHostCPU.Total
|
|
idleDelta := current.Idle - lastHostCPU.Idle
|
|
lastHostCPU = current
|
|
|
|
if totalDelta == 0 {
|
|
return 0
|
|
}
|
|
|
|
usage := (1 - float64(idleDelta)/float64(totalDelta)) * 100
|
|
if usage < 0 {
|
|
return 0
|
|
}
|
|
if usage > 100 {
|
|
return 100
|
|
}
|
|
return usage
|
|
}
|
|
|
|
func readCPUTimes() (cpuTimes, error) {
|
|
f, err := os.Open("/proc/stat")
|
|
if err != nil {
|
|
return cpuTimes{}, err
|
|
}
|
|
defer f.Close()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
if !scanner.Scan() {
|
|
return cpuTimes{}, scanner.Err()
|
|
}
|
|
|
|
fields := strings.Fields(scanner.Text())
|
|
if len(fields) < 8 || fields[0] != "cpu" {
|
|
return cpuTimes{}, nil
|
|
}
|
|
|
|
var values []uint64
|
|
for _, field := range fields[1:] {
|
|
value, _ := strconv.ParseUint(field, 10, 64)
|
|
values = append(values, value)
|
|
}
|
|
|
|
var total uint64
|
|
for _, value := range values {
|
|
total += value
|
|
}
|
|
|
|
idle := values[3]
|
|
if len(values) > 4 {
|
|
idle += values[4]
|
|
}
|
|
|
|
return cpuTimes{Total: total, Idle: idle}, nil
|
|
}
|
|
|
|
func parseSizeGB(s string) (int64, error) {
|
|
s = strings.TrimSuffix(s, "G")
|
|
s = strings.TrimSpace(s)
|
|
val, err := strconv.ParseInt(s, 10, 64)
|
|
return val, err
|
|
}
|
|
|
|
func parseSizeGBf(s string) (float64, error) {
|
|
s = strings.TrimSuffix(s, "G")
|
|
s = strings.TrimSpace(s)
|
|
val, err := strconv.ParseFloat(s, 64)
|
|
return val, err
|
|
}
|
|
|
|
func getHostRates() (NetworkInfo, DiskIOInfo) {
|
|
rx, tx := readHostNetworkBytes()
|
|
readBytes, writeBytes := readHostDiskBytes()
|
|
now := unixNano()
|
|
|
|
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
|
|
publicIPv4 := detectDisplayPublicIPv4()
|
|
network.PublicIPv4 = publicIPv4.Address
|
|
network.PublicIPv4Interface = publicIPv4.Interface
|
|
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
|
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
|
if len(network.IPv6Prefixes) > 0 {
|
|
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
|
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
|
}
|
|
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
|
|
|
hostIOMu.Lock()
|
|
defer hostIOMu.Unlock()
|
|
|
|
if lastHostIO.At == 0 {
|
|
lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now}
|
|
return network, diskIO
|
|
}
|
|
|
|
elapsed := float64(now-lastHostIO.At) / 1_000_000_000
|
|
if elapsed > 0 {
|
|
if rx >= lastHostIO.RXBytes {
|
|
network.RXBps = float64(rx-lastHostIO.RXBytes) / elapsed
|
|
}
|
|
if tx >= lastHostIO.TXBytes {
|
|
network.TXBps = float64(tx-lastHostIO.TXBytes) / elapsed
|
|
}
|
|
if readBytes >= lastHostIO.ReadBytes {
|
|
diskIO.ReadBps = float64(readBytes-lastHostIO.ReadBytes) / elapsed
|
|
}
|
|
if writeBytes >= lastHostIO.WriteBytes {
|
|
diskIO.WriteBps = float64(writeBytes-lastHostIO.WriteBytes) / elapsed
|
|
}
|
|
}
|
|
|
|
lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now}
|
|
return network, diskIO
|
|
}
|
|
|
|
func readHostNetworkBytes() (uint64, uint64) {
|
|
ifaces := detectHostTrafficInterfaces()
|
|
if len(ifaces) == 0 {
|
|
ifaces = fallbackHostTrafficInterfaces()
|
|
}
|
|
|
|
var rx, tx uint64
|
|
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 {
|
|
return 0, 0
|
|
}
|
|
defer f.Close()
|
|
|
|
var readSectors, writeSectors uint64
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
fields := strings.Fields(scanner.Text())
|
|
if len(fields) < 14 {
|
|
continue
|
|
}
|
|
device := fields[2]
|
|
if strings.HasPrefix(device, "loop") ||
|
|
strings.HasPrefix(device, "ram") ||
|
|
strings.HasPrefix(device, "fd") ||
|
|
strings.HasPrefix(device, "sr") {
|
|
continue
|
|
}
|
|
read, _ := strconv.ParseUint(fields[5], 10, 64)
|
|
write, _ := strconv.ParseUint(fields[9], 10, 64)
|
|
readSectors += read
|
|
writeSectors += write
|
|
}
|
|
return readSectors * 512, writeSectors * 512
|
|
}
|
|
|
|
func readUintFile(path string) uint64 {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
value, _ := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
|
|
return value
|
|
}
|
|
|
|
func unixNano() int64 {
|
|
return time.Now().UnixNano()
|
|
}
|
|
|
|
func getLoadInfo() LoadInfo {
|
|
f, err := os.Open("/proc/loadavg")
|
|
if err != nil {
|
|
return LoadInfo{}
|
|
}
|
|
defer f.Close()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
if !scanner.Scan() {
|
|
return LoadInfo{}
|
|
}
|
|
|
|
fields := strings.Fields(scanner.Text())
|
|
if len(fields) < 3 {
|
|
return LoadInfo{}
|
|
}
|
|
|
|
load1, _ := strconv.ParseFloat(fields[0], 64)
|
|
load5, _ := strconv.ParseFloat(fields[1], 64)
|
|
load15, _ := strconv.ParseFloat(fields[2], 64)
|
|
return LoadInfo{Load1: load1, Load5: load5, Load15: load15}
|
|
}
|
|
|
|
func HandleHostReport(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
|
return
|
|
}
|
|
if !requireScope(w, r, "host:read") {
|
|
return
|
|
}
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getHostProbeReport()})
|
|
}
|
|
|
|
func getHostProbeReport() HostProbeReport {
|
|
host, _ := os.Hostname()
|
|
mem := getMemoryInfo()
|
|
report := HostProbeReport{
|
|
GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
|
|
Hostname: host,
|
|
Kernel: strings.TrimSpace(runCommandOutput(2*time.Second, "uname", "-srmo")),
|
|
OS: detectOSRelease(),
|
|
CPU: detectHostCPUProbe(),
|
|
Memory: HostMemoryProbe{TotalMB: mem.TotalMB, UsedMB: mem.UsedMB, FreeMB: mem.FreeMB, Modules: detectMemoryModules()},
|
|
Disks: detectHostDisks(),
|
|
NetworkInterfaces: detectHostNICs(),
|
|
PublicIPv4: detectAllPublicIPv4(),
|
|
IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(),
|
|
Gateways: detectGateways(),
|
|
GPUs: detectGPUs(),
|
|
System: detectSystemProbe(),
|
|
Environment: detectHostEnvironment(),
|
|
}
|
|
report.IPv6Addresses = collectIPv6Addresses(report.NetworkInterfaces)
|
|
report.IPv4Addresses = collectIPv4Addresses(report.NetworkInterfaces)
|
|
report.IPv4Prefixes = detectPublicIPv4Prefixes(report.NetworkInterfaces, report.Gateways)
|
|
report.Runtime = detectRuntimeProbe(report.Environment)
|
|
report.CPU.HasIntegratedGPU = hasIntegratedGPU(report.GPUs)
|
|
return report
|
|
}
|
|
|
|
func detectOSRelease() string {
|
|
values := readKeyValueFile("/etc/os-release", "=")
|
|
if pretty := trimOSReleaseValue(values["PRETTY_NAME"]); pretty != "" {
|
|
return pretty
|
|
}
|
|
if name := trimOSReleaseValue(values["NAME"]); name != "" {
|
|
return name
|
|
}
|
|
return strings.TrimSpace(runCommandOutput(2*time.Second, "uname", "-o"))
|
|
}
|
|
|
|
func trimOSReleaseValue(value string) string {
|
|
return strings.Trim(strings.TrimSpace(value), `"`)
|
|
}
|
|
|
|
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") {
|
|
fields := strings.SplitN(line, ":", 2)
|
|
if len(fields) != 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(fields[0])
|
|
keyLower := strings.ToLower(key)
|
|
value := strings.TrimSpace(fields[1])
|
|
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 "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" || flag == "virt" {
|
|
probe.Virtualization = true
|
|
probe.VirtualizationKey = flag
|
|
}
|
|
if !seenFlags[flag] {
|
|
seenFlags[flag] = true
|
|
probe.Flags = append(probe.Flags, flag)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
out := runCommandOutput(4*time.Second, "dmidecode", "-t", "memory")
|
|
modules := make([]HostMemoryModule, 0)
|
|
var current HostMemoryModule
|
|
inDevice := false
|
|
flush := func() {
|
|
if !inDevice {
|
|
return
|
|
}
|
|
if current.Size != "" && !strings.EqualFold(current.Size, "No Module Installed") {
|
|
modules = append(modules, current)
|
|
}
|
|
current = HostMemoryModule{}
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.HasPrefix(line, "Memory Device") {
|
|
flush()
|
|
inDevice = true
|
|
continue
|
|
}
|
|
if !inDevice {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(strings.TrimSpace(line), ":", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key, value := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
|
switch key {
|
|
case "Locator":
|
|
current.Locator = value
|
|
case "Size":
|
|
current.Size = value
|
|
case "Type":
|
|
current.Type = value
|
|
case "Speed":
|
|
current.Speed = value
|
|
case "Manufacturer":
|
|
current.Manufacturer = value
|
|
case "Part Number":
|
|
current.PartNumber = value
|
|
case "Serial Number":
|
|
current.SerialNumber = value
|
|
}
|
|
}
|
|
flush()
|
|
return modules
|
|
}
|
|
|
|
func detectHostDisks() []HostDiskProbe {
|
|
disks := make([]HostDiskProbe, 0)
|
|
entries, err := os.ReadDir("/sys/block")
|
|
if err != nil {
|
|
return disks
|
|
}
|
|
mounts := detectMountpointsByDevice()
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "fd") || strings.HasPrefix(name, "sr") {
|
|
continue
|
|
}
|
|
base := filepath.Join("/sys/block", name)
|
|
path := "/dev/" + name
|
|
model := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name")))
|
|
vendor := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/vendor")))
|
|
virtual := isVirtualBlockDevice(name, model, vendor)
|
|
disk := HostDiskProbe{
|
|
Name: name,
|
|
Path: path,
|
|
Model: model,
|
|
Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))),
|
|
SizeBytes: readUintFile(filepath.Join(base, "size")) * 512,
|
|
Type: detectDiskType(base, name, virtual),
|
|
Virtual: virtual,
|
|
Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1",
|
|
Mountpoints: mounts[name],
|
|
}
|
|
if !virtual {
|
|
disk.SMART = detectDiskSMART(path)
|
|
}
|
|
disk.Health = disk.SMARTHealth()
|
|
disk.HealthDetail = disk.SMARTDetail()
|
|
disks = append(disks, disk)
|
|
}
|
|
sort.Slice(disks, func(i, j int) bool { return disks[i].Name < disks[j].Name })
|
|
return disks
|
|
}
|
|
|
|
func detectDiskType(base, name string, virtual bool) string {
|
|
if virtual {
|
|
return "Virtual"
|
|
}
|
|
if strings.HasPrefix(name, "nvme") {
|
|
return "NVMe"
|
|
}
|
|
if strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1" {
|
|
return "HDD"
|
|
}
|
|
return "SSD"
|
|
}
|
|
|
|
func isVirtualBlockDevice(name, model, vendor string) bool {
|
|
lower := strings.ToLower(strings.TrimSpace(name + " " + model + " " + vendor))
|
|
if strings.HasPrefix(name, "vd") || strings.HasPrefix(name, "xvd") {
|
|
return true
|
|
}
|
|
for _, token := range []string{
|
|
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
|
|
"amazon elastic block store", "google persistentdisk", "microsoft", "blockvolume",
|
|
} {
|
|
if strings.Contains(lower, token) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (disk HostDiskProbe) SMARTHealth() string {
|
|
if disk.Virtual {
|
|
return "virtual"
|
|
}
|
|
if disk.SMART.Available && disk.Health != "" {
|
|
return disk.Health
|
|
}
|
|
return disk.SMART.Health()
|
|
}
|
|
|
|
func (disk HostDiskProbe) SMARTDetail() string {
|
|
if disk.Virtual {
|
|
return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看"
|
|
}
|
|
return disk.SMART.Detail()
|
|
}
|
|
|
|
func (smart HostDiskSMARTProbe) Health() string {
|
|
if !smart.Available {
|
|
return "unknown"
|
|
}
|
|
if smart.MediaErrors > 0 {
|
|
return "failed"
|
|
}
|
|
return "ok"
|
|
}
|
|
|
|
func (smart HostDiskSMARTProbe) Detail() string {
|
|
if !smart.Available {
|
|
return "smartctl not installed or no SMART output"
|
|
}
|
|
parts := []string{"SMART passed"}
|
|
if smart.LifeUsedPercent != nil {
|
|
parts = append(parts, fmt.Sprintf("寿命已用 %d%%", *smart.LifeUsedPercent))
|
|
}
|
|
if smart.PowerOnHours > 0 {
|
|
parts = append(parts, fmt.Sprintf("通电 %dh", smart.PowerOnHours))
|
|
}
|
|
if smart.WrittenDataBytes > 0 {
|
|
parts = append(parts, fmt.Sprintf("写入 %s", formatBytesText(smart.WrittenDataBytes)))
|
|
}
|
|
if smart.ReadDataBytes > 0 {
|
|
parts = append(parts, fmt.Sprintf("读取 %s", formatBytesText(smart.ReadDataBytes)))
|
|
}
|
|
if smart.MediaErrors > 0 {
|
|
parts = append(parts, fmt.Sprintf("介质错误 %d", smart.MediaErrors))
|
|
}
|
|
return strings.Join(parts, " | ")
|
|
}
|
|
|
|
func detectDiskSMART(path string) HostDiskSMARTProbe {
|
|
smart := HostDiskSMARTProbe{}
|
|
if !commandExists("smartctl") {
|
|
return smart
|
|
}
|
|
out := runCommandOutput(8*time.Second, "smartctl", "-a", "-j", path)
|
|
if strings.TrimSpace(out) == "" {
|
|
return smart
|
|
}
|
|
var data smartctlOutput
|
|
if err := json.Unmarshal([]byte(out), &data); err != nil {
|
|
return smart
|
|
}
|
|
smart.Available = true
|
|
smart.PowerOnHours = data.PowerOnTime.Hours
|
|
smart.PowerCycleCount = data.PowerCycleCount
|
|
if data.NVMe.PowerOnHours > 0 {
|
|
smart.PowerOnHours = uint64ToInt64(data.NVMe.PowerOnHours)
|
|
}
|
|
if data.NVMe.PowerCycles > 0 {
|
|
smart.PowerCycleCount = uint64ToInt64(data.NVMe.PowerCycles)
|
|
}
|
|
if data.NVMe.PercentageUsed > 0 {
|
|
if used, ok := smartPercentToInt(data.NVMe.PercentageUsed); ok {
|
|
smart.LifeUsedPercent = &used
|
|
}
|
|
}
|
|
smart.ReadDataBytes = data.NVMe.DataUnitsRead * 512000
|
|
smart.WrittenDataBytes = data.NVMe.DataUnitsWritten * 512000
|
|
smart.ReadCommands = data.NVMe.HostReadCommands
|
|
smart.WriteCommands = data.NVMe.HostWriteCommands
|
|
smart.MediaErrors = data.NVMe.MediaErrors
|
|
|
|
parseATAAttributes(&smart, data.ATASmartAttributes.Table)
|
|
if data.SmartStatus != nil && !data.SmartStatus.Passed {
|
|
smart.MediaErrors++
|
|
}
|
|
return smart
|
|
}
|
|
|
|
type smartctlOutput struct {
|
|
SmartStatus *struct {
|
|
Passed bool `json:"passed"`
|
|
} `json:"smart_status"`
|
|
PowerOnTime struct {
|
|
Hours int64 `json:"hours"`
|
|
} `json:"power_on_time"`
|
|
PowerCycleCount int64 `json:"power_cycle_count"`
|
|
ATASmartAttributes struct {
|
|
Table []smartctlAttribute `json:"table"`
|
|
} `json:"ata_smart_attributes"`
|
|
NVMe struct {
|
|
PercentageUsed uint64 `json:"percentage_used"`
|
|
DataUnitsRead uint64 `json:"data_units_read"`
|
|
DataUnitsWritten uint64 `json:"data_units_written"`
|
|
HostReadCommands uint64 `json:"host_reads"`
|
|
HostWriteCommands uint64 `json:"host_writes"`
|
|
PowerOnHours uint64 `json:"power_on_hours"`
|
|
PowerCycles uint64 `json:"power_cycles"`
|
|
MediaErrors uint64 `json:"media_errors"`
|
|
} `json:"nvme_smart_health_information_log"`
|
|
}
|
|
|
|
type smartctlAttribute struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Value int `json:"value"`
|
|
Raw struct {
|
|
Value json.Number `json:"value"`
|
|
String string `json:"string"`
|
|
} `json:"raw"`
|
|
}
|
|
|
|
func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
|
|
for _, attr := range attrs {
|
|
name := normalizeSMARTAttrName(attr.Name)
|
|
raw := smartAttrRawUint(attr)
|
|
rawText := smartAttrRawText(attr)
|
|
switch name {
|
|
case "poweronhours":
|
|
if smart.PowerOnHours == 0 {
|
|
if parsed, ok := smartAttrRawInt64(attr); ok {
|
|
smart.PowerOnHours = parsed
|
|
}
|
|
}
|
|
case "powercyclecount":
|
|
if smart.PowerCycleCount == 0 {
|
|
if parsed, ok := smartAttrRawInt64(attr); ok {
|
|
smart.PowerCycleCount = parsed
|
|
}
|
|
}
|
|
case "totallbaswritten":
|
|
if smart.WrittenDataBytes == 0 {
|
|
smart.WrittenDataBytes = raw * 512
|
|
}
|
|
case "totallbasread":
|
|
if smart.ReadDataBytes == 0 {
|
|
smart.ReadDataBytes = raw * 512
|
|
}
|
|
case "hostwrites32mib":
|
|
if smart.WrittenDataBytes == 0 {
|
|
smart.WrittenDataBytes = raw * 32 * 1024 * 1024
|
|
}
|
|
case "hostreads32mib":
|
|
if smart.ReadDataBytes == 0 {
|
|
smart.ReadDataBytes = raw * 32 * 1024 * 1024
|
|
}
|
|
case "hostwritecommands":
|
|
smart.WriteCommands = raw
|
|
case "hostreadcommands":
|
|
smart.ReadCommands = raw
|
|
case "wearlevelingcount":
|
|
smart.WearLevelingCount = rawText
|
|
if smart.LifeUsedPercent == nil && attr.Value > 0 && attr.Value <= 100 {
|
|
used := 100 - attr.Value
|
|
if used < 0 {
|
|
used = 0
|
|
}
|
|
smart.LifeUsedPercent = &used
|
|
}
|
|
case "percentlifetimeremain", "mediawearoutindicator":
|
|
if smart.LifeUsedPercent == nil {
|
|
remaining := attr.Value
|
|
if raw > 0 && raw <= 100 {
|
|
if parsed, ok := smartPercentToInt(raw); ok {
|
|
remaining = parsed
|
|
}
|
|
}
|
|
used := 100 - remaining
|
|
if used < 0 {
|
|
used = 0
|
|
}
|
|
if used <= 100 {
|
|
smart.LifeUsedPercent = &used
|
|
}
|
|
}
|
|
case "percentageused":
|
|
if smart.LifeUsedPercent == nil && raw <= 255 {
|
|
if used, ok := smartPercentToInt(raw); ok {
|
|
smart.LifeUsedPercent = &used
|
|
}
|
|
}
|
|
case "erasefailcounttotal", "erasecount", "nandwrites", "programfailcnttotal":
|
|
if smart.EraseCount == "" && rawText != "" {
|
|
smart.EraseCount = rawText
|
|
}
|
|
case "mediaerrors":
|
|
smart.MediaErrors = raw
|
|
}
|
|
}
|
|
}
|
|
|
|
func uint64ToInt64(value uint64) int64 {
|
|
if value > 9223372036854775807 {
|
|
return 0
|
|
}
|
|
return int64(value)
|
|
}
|
|
|
|
func smartPercentToInt(value uint64) (int, bool) {
|
|
if value > 2147483647 {
|
|
return 0, false
|
|
}
|
|
return int(value), true
|
|
}
|
|
|
|
func normalizeSMARTAttrName(name string) string {
|
|
name = strings.ToLower(name)
|
|
var b strings.Builder
|
|
for _, ch := range name {
|
|
if ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' {
|
|
b.WriteRune(ch)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func smartAttrRawText(attr smartctlAttribute) string {
|
|
if attr.Raw.String != "" {
|
|
return attr.Raw.String
|
|
}
|
|
if attr.Raw.Value != "" {
|
|
return attr.Raw.Value.String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func smartAttrRawInt64(attr smartctlAttribute) (int64, bool) {
|
|
text := smartAttrRawText(attr)
|
|
if value, err := strconv.ParseInt(text, 10, 64); err == nil {
|
|
return value, true
|
|
}
|
|
digits := firstUintText(text)
|
|
if digits == "" {
|
|
return 0, false
|
|
}
|
|
value, err := strconv.ParseInt(digits, 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
func smartAttrRawUint(attr smartctlAttribute) uint64 {
|
|
text := smartAttrRawText(attr)
|
|
if value, err := strconv.ParseUint(text, 10, 64); err == nil {
|
|
return value
|
|
}
|
|
digits := firstUintText(text)
|
|
if digits == "" {
|
|
return 0
|
|
}
|
|
value, _ := strconv.ParseUint(digits, 10, 64)
|
|
return value
|
|
}
|
|
|
|
func firstUintText(value string) string {
|
|
start := -1
|
|
for i, ch := range value {
|
|
if ch >= '0' && ch <= '9' {
|
|
if start < 0 {
|
|
start = i
|
|
}
|
|
continue
|
|
}
|
|
if start >= 0 {
|
|
return value[start:i]
|
|
}
|
|
}
|
|
if start >= 0 {
|
|
return value[start:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func detectMountpointsByDevice() map[string][]string {
|
|
result := map[string][]string{}
|
|
f, err := os.Open("/proc/mounts")
|
|
if err != nil {
|
|
return result
|
|
}
|
|
defer f.Close()
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
fields := strings.Fields(scanner.Text())
|
|
if len(fields) < 2 || !strings.HasPrefix(fields[0], "/dev/") {
|
|
continue
|
|
}
|
|
dev := strings.TrimPrefix(filepath.Base(fields[0]), "/dev/")
|
|
parent := diskParentName(dev)
|
|
result[parent] = append(result[parent], fields[1])
|
|
}
|
|
return result
|
|
}
|
|
|
|
func diskParentName(dev string) string {
|
|
for _, suffix := range []string{"p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9"} {
|
|
if strings.HasSuffix(dev, suffix) && strings.HasPrefix(dev, "nvme") {
|
|
return strings.TrimSuffix(dev, suffix)
|
|
}
|
|
}
|
|
for len(dev) > 0 && dev[len(dev)-1] >= '0' && dev[len(dev)-1] <= '9' {
|
|
dev = dev[:len(dev)-1]
|
|
}
|
|
return dev
|
|
}
|
|
|
|
func detectHostNICs() []HostNICProbe {
|
|
entries, err := os.ReadDir("/sys/class/net")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
ipv4, ipv6 := detectInterfaceIPs()
|
|
nics := make([]HostNICProbe, 0)
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if name == "lo" {
|
|
continue
|
|
}
|
|
base := filepath.Join("/sys/class/net", name)
|
|
speed, _ := strconv.Atoi(strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "speed"))))
|
|
nic := HostNICProbe{
|
|
Name: name,
|
|
MAC: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "address"))),
|
|
State: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "operstate"))),
|
|
SpeedMbps: speed,
|
|
Driver: strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", fmt.Sprintf("basename $(readlink -f /sys/class/net/%s/device/driver 2>/dev/null) 2>/dev/null", shellQuoteSimple(name)))),
|
|
Model: detectNICModel(name),
|
|
IPv4: ipv4[name],
|
|
IPv6: ipv6[name],
|
|
}
|
|
nics = append(nics, nic)
|
|
}
|
|
sort.Slice(nics, func(i, j int) bool { return nics[i].Name < nics[j].Name })
|
|
return nics
|
|
}
|
|
|
|
func detectNICModel(name string) string {
|
|
out := runCommandOutput(2*time.Second, "sh", "-c", fmt.Sprintf("lspci -D 2>/dev/null | grep -iE 'ethernet|network' | head -n 1 || true"))
|
|
if out != "" {
|
|
return strings.TrimSpace(out)
|
|
}
|
|
return strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "device", "uevent")))
|
|
}
|
|
|
|
func detectInterfaceIPs() (map[string][]HostIPProbe, map[string][]HostIPProbe) {
|
|
ipv4 := map[string][]HostIPProbe{}
|
|
ipv6 := map[string][]HostIPProbe{}
|
|
for _, family := range []struct {
|
|
arg string
|
|
dst map[string][]HostIPProbe
|
|
}{{"-4", ipv4}, {"-6", ipv6}} {
|
|
out := runCommandOutput(3*time.Second, "ip", "-o", family.arg, "addr", "show")
|
|
for _, line := range strings.Split(out, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 4 {
|
|
continue
|
|
}
|
|
iface := strings.TrimSuffix(fields[1], ":")
|
|
addr := fields[3]
|
|
ip, network, err := net.ParseCIDR(addr)
|
|
if err != nil || ip == nil || network == nil {
|
|
continue
|
|
}
|
|
ones, _ := network.Mask.Size()
|
|
scope := ""
|
|
for i, field := range fields {
|
|
if field == "scope" && i+1 < len(fields) {
|
|
scope = fields[i+1]
|
|
}
|
|
}
|
|
family.dst[iface] = append(family.dst[iface], HostIPProbe{
|
|
Interface: iface,
|
|
Address: ip.String(),
|
|
PrefixLen: ones,
|
|
Scope: scope,
|
|
})
|
|
}
|
|
}
|
|
return ipv4, ipv6
|
|
}
|
|
|
|
func detectAllPublicIPv4() []string {
|
|
seen := map[string]bool{}
|
|
result := make([]string, 0)
|
|
if pub := lxc.DetectPublicIPv4(); pub.Address != "" {
|
|
if ip := net.ParseIP(pub.Address); isPublicIPv4(ip) {
|
|
seen[pub.Address] = true
|
|
result = append(result, pub.Address)
|
|
}
|
|
}
|
|
out := runCommandOutput(3*time.Second, "ip", "-o", "-4", "addr", "show", "scope", "global")
|
|
for _, line := range strings.Split(out, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 4 {
|
|
continue
|
|
}
|
|
ip, _, err := net.ParseCIDR(fields[3])
|
|
if err != nil || ip == nil {
|
|
continue
|
|
}
|
|
if !isPublicIPv4(ip) {
|
|
continue
|
|
}
|
|
value := ip.String()
|
|
if !seen[value] {
|
|
seen[value] = true
|
|
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 {
|
|
for _, ip := range nic.IPv4 {
|
|
parsed := net.ParseIP(ip.Address)
|
|
if isPublicIPv4(parsed) {
|
|
result = append(result, ip)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func detectPublicIPv4Prefixes(nics []HostNICProbe, gateways []HostGatewayProbe) []HostIPv4PrefixProbe {
|
|
result := make([]HostIPv4PrefixProbe, 0)
|
|
seen := map[string]bool{}
|
|
gatewayByIface := map[string]string{}
|
|
for _, gateway := range gateways {
|
|
if gateway.Family == "ipv4" && gateway.Interface != "" && gateway.Gateway != "" {
|
|
gatewayByIface[gateway.Interface] = gateway.Gateway
|
|
}
|
|
}
|
|
add := func(item HostIPv4PrefixProbe) {
|
|
if item.Prefix == "" || item.Interface == "" {
|
|
return
|
|
}
|
|
key := item.Interface + "|" + item.Prefix
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
result = append(result, item)
|
|
}
|
|
for _, nic := range nics {
|
|
for _, ip := range nic.IPv4 {
|
|
parsed := net.ParseIP(ip.Address)
|
|
if !isPublicIPv4(parsed) || ip.PrefixLen <= 0 || ip.PrefixLen > 32 {
|
|
continue
|
|
}
|
|
prefix, subnet := ipv4PrefixAndMask(parsed, ip.PrefixLen)
|
|
add(HostIPv4PrefixProbe{
|
|
Interface: nic.Name,
|
|
Address: ip.Address,
|
|
Prefix: prefix,
|
|
PrefixLen: ip.PrefixLen,
|
|
SubnetMask: subnet,
|
|
Gateway: gatewayByIface[nic.Name],
|
|
Source: "address",
|
|
})
|
|
}
|
|
}
|
|
for _, item := range detectIPv4RoutePrefixes(gatewayByIface) {
|
|
add(item)
|
|
}
|
|
sort.SliceStable(result, func(i, j int) bool {
|
|
if result[i].Interface == result[j].Interface {
|
|
return result[i].Prefix < result[j].Prefix
|
|
}
|
|
return result[i].Interface < result[j].Interface
|
|
})
|
|
return result
|
|
}
|
|
|
|
func detectIPv4RoutePrefixes(gatewayByIface map[string]string) []HostIPv4PrefixProbe {
|
|
out := runCommandOutput(3*time.Second, "ip", "-4", "route", "show")
|
|
result := make([]HostIPv4PrefixProbe, 0)
|
|
for _, line := range strings.Split(out, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 0 || fields[0] == "default" {
|
|
continue
|
|
}
|
|
_, network, err := net.ParseCIDR(fields[0])
|
|
if err != nil || network == nil || network.IP.To4() == nil {
|
|
continue
|
|
}
|
|
if !isPublicIPv4(network.IP) {
|
|
continue
|
|
}
|
|
iface := ""
|
|
gateway := ""
|
|
src := ""
|
|
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 field == "src" && i+1 < len(fields) {
|
|
src = fields[i+1]
|
|
}
|
|
}
|
|
if iface == "" || isContainerLikeInterfaceName(iface) {
|
|
continue
|
|
}
|
|
ones, bits := network.Mask.Size()
|
|
if bits != 32 || ones <= 0 || ones > 32 {
|
|
continue
|
|
}
|
|
if gateway == "" {
|
|
gateway = gatewayByIface[iface]
|
|
}
|
|
prefix, subnet := ipv4PrefixAndMask(network.IP, ones)
|
|
result = append(result, HostIPv4PrefixProbe{
|
|
Interface: iface,
|
|
Address: src,
|
|
Prefix: prefix,
|
|
PrefixLen: ones,
|
|
SubnetMask: subnet,
|
|
Gateway: gateway,
|
|
Source: "route",
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func ipv4PrefixAndMask(ip net.IP, prefixLen int) (string, string) {
|
|
v4 := ip.To4()
|
|
if v4 == nil {
|
|
return "", ""
|
|
}
|
|
mask := net.CIDRMask(prefixLen, 32)
|
|
network := v4.Mask(mask)
|
|
subnet := fmt.Sprintf("%d.%d.%d.%d", mask[0], mask[1], mask[2], mask[3])
|
|
return fmt.Sprintf("%s/%d", network.String(), prefixLen), subnet
|
|
}
|
|
|
|
func isPublicIPv4(ip net.IP) bool {
|
|
ip = ip.To4()
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
return !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsMulticast() && !ip.IsUnspecified()
|
|
}
|
|
|
|
func isContainerLikeInterfaceName(iface string) bool {
|
|
prefixes := []string{"lo", "lxc", "docker", "br-", "veth", "virbr", "cni", "flannel", "cali", "kube", "dummy", "ifb"}
|
|
for _, prefix := range prefixes {
|
|
if iface == prefix || strings.HasPrefix(iface, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
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 {
|
|
for _, ip := range nic.IPv6 {
|
|
if ip.Scope == "global" {
|
|
result = append(result, ip)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func detectGateways() []HostGatewayProbe {
|
|
gateways := make([]HostGatewayProbe, 0)
|
|
for _, item := range []struct {
|
|
family string
|
|
args []string
|
|
}{{"ipv4", []string{"-4", "route", "show", "default"}}, {"ipv6", []string{"-6", "route", "show", "default"}}} {
|
|
out := runCommandOutput(3*time.Second, "ip", item.args...)
|
|
for _, line := range strings.Split(out, "\n") {
|
|
fields := strings.Fields(line)
|
|
gw := ""
|
|
iface := ""
|
|
for i, field := range fields {
|
|
if field == "via" && i+1 < len(fields) {
|
|
gw = fields[i+1]
|
|
}
|
|
if field == "dev" && i+1 < len(fields) {
|
|
iface = fields[i+1]
|
|
}
|
|
}
|
|
if gw != "" || iface != "" {
|
|
gateways = append(gateways, HostGatewayProbe{Family: item.family, Interface: iface, Gateway: gw})
|
|
}
|
|
}
|
|
}
|
|
return gateways
|
|
}
|
|
|
|
func detectGPUs() []HostGPUProbe {
|
|
gpus := make([]HostGPUProbe, 0)
|
|
out := runCommandOutput(3*time.Second, "sh", "-c", "lspci -nnk 2>/dev/null | grep -iEA3 'vga|3d|display' || true")
|
|
var current *HostGPUProbe
|
|
for _, line := range strings.Split(out, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
lower := strings.ToLower(trimmed)
|
|
if strings.Contains(lower, "vga") || strings.Contains(lower, "3d controller") || strings.Contains(lower, "display controller") {
|
|
gpus = append(gpus, HostGPUProbe{Name: trimmed, Vendor: detectGPUVendor(trimmed), Type: detectGPUType(trimmed)})
|
|
current = &gpus[len(gpus)-1]
|
|
continue
|
|
}
|
|
if current != nil && strings.HasPrefix(trimmed, "Kernel driver in use:") {
|
|
current.Driver = strings.TrimSpace(strings.TrimPrefix(trimmed, "Kernel driver in use:"))
|
|
}
|
|
}
|
|
return gpus
|
|
}
|
|
|
|
func detectGPUVendor(value string) string {
|
|
lower := strings.ToLower(value)
|
|
switch {
|
|
case strings.Contains(lower, "intel"):
|
|
return "Intel"
|
|
case strings.Contains(lower, "nvidia"):
|
|
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"
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
return "discrete"
|
|
}
|
|
|
|
func hasIntegratedGPU(gpus []HostGPUProbe) bool {
|
|
for _, gpu := range gpus {
|
|
if gpu.Type == "integrated" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()),
|
|
DevKVM: devKVM,
|
|
NestedVirtualization: nested,
|
|
NestedDetail: detail,
|
|
SupportMode: "unsupported",
|
|
}
|
|
if probe.KVMAvailable {
|
|
probe.SupportMode = "kvm_lxc"
|
|
} else if probe.LXCAvailable {
|
|
probe.SupportMode = "lxc_only"
|
|
}
|
|
return probe
|
|
}
|
|
|
|
func detectNestedVirtualization() (bool, string) {
|
|
paths := []string{
|
|
"/sys/module/kvm_intel/parameters/nested",
|
|
"/sys/module/kvm_amd/parameters/nested",
|
|
}
|
|
for _, path := range paths {
|
|
value := strings.TrimSpace(readFirstExistingFile(path))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
enabled := strings.EqualFold(value, "Y") || value == "1"
|
|
return enabled, filepath.Base(filepath.Dir(filepath.Dir(path))) + "=" + value
|
|
}
|
|
if fileExists("/dev/kvm") {
|
|
return true, "/dev/kvm present"
|
|
}
|
|
return false, "no kvm nested parameter or /dev/kvm"
|
|
}
|
|
|
|
func detectSystemProbe() HostSystemProbe {
|
|
uptime := int64(0)
|
|
if data, err := os.ReadFile("/proc/uptime"); err == nil {
|
|
first := strings.Fields(string(data))
|
|
if len(first) > 0 {
|
|
value, _ := strconv.ParseFloat(first[0], 64)
|
|
uptime = int64(value)
|
|
}
|
|
}
|
|
return HostSystemProbe{
|
|
UptimeSeconds: uptime,
|
|
UptimeText: formatDurationText(uptime),
|
|
ProcessCount: countProcesses(),
|
|
}
|
|
}
|
|
|
|
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", ""),
|
|
commandCheck("lxc-start", "LXC 启动工具", true, "lxc-start", ""),
|
|
commandCheck("iptables", "iptables 网络规则", true, "iptables", ""),
|
|
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
|
|
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
|
|
commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
|
|
qemuCheck,
|
|
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"))})
|
|
checks = append(checks, HostEnvCheck{Key: "lxcfs", Label: "lxcfs 服务", OK: serviceActive("lxcfs"), Required: false, Detail: serviceDetail("lxcfs")})
|
|
checks = append(checks, HostEnvCheck{Key: "libvirt", Label: "libvirt 服务", OK: serviceActive("libvirtd") || serviceActive("virtqemud"), Required: false, Detail: firstNonEmpty(serviceDetail("libvirtd"), serviceDetail("virtqemud"))})
|
|
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"
|
|
if ok {
|
|
detail = commandVersionDetail(cmd)
|
|
if detail == "" {
|
|
detail = "installed"
|
|
}
|
|
} else if fallback != "" {
|
|
detail = fallback
|
|
}
|
|
return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail}
|
|
}
|
|
|
|
func commandVersionDetail(cmd string) string {
|
|
switch cmd {
|
|
case "ip":
|
|
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", "ip -V 2>&1 | head -n 1"))
|
|
default:
|
|
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return check.OK
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func serviceActive(name string) bool {
|
|
if commandExists("systemctl") {
|
|
return strings.TrimSpace(runCommandOutput(2*time.Second, "systemctl", "is-active", name)) == "active"
|
|
}
|
|
if commandExists("rc-service") {
|
|
return strings.Contains(runCommandOutput(2*time.Second, "rc-service", name, "status"), "started")
|
|
}
|
|
return false
|
|
}
|
|
|
|
func serviceDetail(name string) string {
|
|
if commandExists("systemctl") {
|
|
return strings.TrimSpace(runCommandOutput(2*time.Second, "systemctl", "is-active", name))
|
|
}
|
|
if commandExists("rc-service") {
|
|
return strings.TrimSpace(runCommandOutput(2*time.Second, "rc-service", name, "status"))
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func readKeyValueFile(path, sep string) map[string]string {
|
|
result := map[string]string{}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
parts := strings.SplitN(line, sep, 2)
|
|
if len(parts) == 2 {
|
|
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func readFirstExistingFile(paths ...string) string {
|
|
for _, path := range paths {
|
|
data, err := os.ReadFile(path)
|
|
if err == nil {
|
|
return string(data)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func commandExists(name string) bool {
|
|
_, err := exec.LookPath(name)
|
|
return err == nil
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
func runCommandOutput(timeout time.Duration, name string, args ...string) string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
|
if err != nil && len(out) == 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
func shortCommandDetail(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
lines := strings.Split(value, "\n")
|
|
if len(lines) > 4 {
|
|
lines = lines[:4]
|
|
}
|
|
return strings.Join(lines, " | ")
|
|
}
|
|
|
|
func shellQuoteSimple(value string) string {
|
|
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
|
}
|
|
|
|
func formatDurationText(seconds int64) string {
|
|
days := seconds / 86400
|
|
seconds %= 86400
|
|
hours := seconds / 3600
|
|
seconds %= 3600
|
|
minutes := seconds / 60
|
|
if days > 0 {
|
|
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
|
}
|
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
|
}
|
|
|
|
func formatBytesText(value uint64) string {
|
|
if value == 0 {
|
|
return "0 B"
|
|
}
|
|
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
|
|
next := float64(value)
|
|
index := 0
|
|
for next >= 1024 && index < len(units)-1 {
|
|
next /= 1024
|
|
index++
|
|
}
|
|
if index == 0 {
|
|
return fmt.Sprintf("%d %s", value, units[index])
|
|
}
|
|
return fmt.Sprintf("%.1f %s", next, units[index])
|
|
}
|
|
|
|
func countProcesses() int {
|
|
entries, err := os.ReadDir("/proc")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
count := 0
|
|
for _, entry := range entries {
|
|
if _, err := strconv.Atoi(entry.Name()); err == nil {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func boolDetail(ok bool) string {
|
|
if ok {
|
|
return "available"
|
|
}
|
|
return "missing"
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" && value != "inactive" && value != "unknown" {
|
|
return value
|
|
}
|
|
}
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|