package main import ( "bytes" "context" "encoding/json" "errors" "flag" "fmt" "io" "math" "net/http" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "unicode" smartgo "github.com/anatol/smart.go" "github.com/klauspost/cpuid" lmsensors "github.com/mt-inside/go-lmsensors" "github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/mem" gopsnet "github.com/shirou/gopsutil/v4/net" smbios "github.com/u-root/u-root/pkg/smbios" "gopkg.in/yaml.v3" ) // ========================================== // 1. TYPES & CONFIGURATION // ========================================== // ConfigStructure représente le fichier config.yaml distant type Config struct { ConfigVersion int `yaml:"config_version"` Backend BackendConfig `yaml:"backend"` Runtime RuntimeConfig `yaml:"runtime"` Logging LoggingConfig `yaml:"logging"` Payload PayloadConfig `yaml:"payload"` Benchmarks BenchmarkConfig `yaml:"benchmarks"` Collection CollectionConfig `yaml:"collection"` RawInfo RawInfoConfig `yaml:"raw_info"` } type BackendConfig struct { URL string `yaml:"url"` Auth AuthConfig `yaml:"auth"` HTTP HTTPConfig `yaml:"http"` } type AuthConfig struct { Mode string `yaml:"mode"` EnvVar string `yaml:"env_var"` } type HTTPConfig struct { TimeoutS int `yaml:"timeout_s"` Retries int `yaml:"retries"` RetryBackoffS int `yaml:"retry_backoff_s"` TLSVerify bool `yaml:"verify"` } type RuntimeConfig struct { MaxTotalRuntime int `yaml:"max_total_runtime_s"` CommandTimeout int `yaml:"command_timeout_s"` TempDir string `yaml:"temp_dir"` Locale string `yaml:"locale"` PathPrepend []string `yaml:"path_prepend"` } type LoggingConfig struct { Level string `yaml:"level"` Progress bool `yaml:"progress"` ShowCommandLines bool `yaml:"show_command_lines"` CaptureRawOutput bool `yaml:"capture_raw_output"` RawOutputMaxKB int `yaml:"raw_output_max_kb"` SavePayloadToTmp bool `yaml:"save_payload_to_tmp"` PayloadTmpDir string `yaml:"payload_tmp_dir"` PayloadFilenamePrefix string `yaml:"payload_filename_prefix"` InteractiveDebugPause bool `yaml:"interactive_pause_on_debug"` } type PayloadConfig struct { Dir string `yaml:"dir"` Prefix string `yaml:"prefix"` Always bool `yaml:"always_save"` } type BenchmarkConfig struct { Enabled bool `yaml:"enabled"` Weights map[string]float64 `yaml:"weights"` CPU BenchmarkTool `yaml:"cpu_sysbench"` Memory BenchmarkTool `yaml:"memory_sysbench"` Disk BenchmarkTool `yaml:"disk_fio"` Network BenchmarkTool `yaml:"network_iperf3"` } type BenchmarkTool struct { Enabled bool `yaml:"enabled"` Tool string `yaml:"tool"` Server string `yaml:"server"` Port int `yaml:"port"` Params map[string]interface{} `yaml:"params"` Safety map[string]interface{} `yaml:"safety"` } type CollectionConfig struct { System CollectionGroup `yaml:"system"` CPU CollectionGroup `yaml:"cpu"` RAM CollectionGroup `yaml:"ram"` Storage CollectionGroup `yaml:"storage"` Network CollectionGroup `yaml:"network"` GPU CollectionGroup `yaml:"gpu"` Audio CollectionGroup `yaml:"audio"` PCI CollectionGroup `yaml:"pci_devices"` USB CollectionGroup `yaml:"usb_devices"` } type CollectionGroup struct { Enabled bool `yaml:"enabled"` } type RawInfoConfig struct { Enabled bool `yaml:"enabled"` Include []string `yaml:"include"` MaxSizeKB int `yaml:"max_size_kb"` } // FinalPayload est la structure JSON envoyée au backend type FinalPayload struct { DeviceIdentifier string `json:"device_identifier"` BenchScriptVersion string `json:"bench_script_version"` BenchClientVersion string `json:"bench_client_version"` Hardware Hardware `json:"hardware"` Results Results `json:"results"` RawInfo map[string]string `json:"raw_info,omitempty"` } type Hardware struct { CPU CPUDetails `json:"cpu"` RAM RAMDetails `json:"ram"` GPU GPUDetails `json:"gpu"` Storage StorageDetails `json:"storage"` PCIDevices []PCIInfo `json:"pci_devices"` USBDevices []USBInfo `json:"usb_devices"` NetworkShares []NetworkShare `json:"network_shares"` Network NetworkDetails `json:"network"` Motherboard MotherboardInfo `json:"motherboard"` OS OSInfo `json:"os"` Sensors SensorInfo `json:"sensors"` Audio AudioInfo `json:"audio"` RawInfo map[string]string `json:"raw_info"` } type CPUDetails struct { Vendor string `json:"vendor"` Model string `json:"model"` Cores int `json:"cores"` Threads int `json:"threads"` BaseFreqGHz float64 `json:"base_freq_ghz"` MaxFreqGHz float64 `json:"max_freq_ghz"` CacheL1KB int `json:"cache_l1_kb"` CacheL2KB int `json:"cache_l2_kb"` CacheL3KB int `json:"cache_l3_kb"` Flags []string `json:"flags"` Microarchitecture string `json:"microarchitecture"` TDPW *float64 `json:"tdp_w"` } type RAMDetails struct { TotalMB int `json:"total_mb"` UsedMB int `json:"used_mb"` FreeMB int `json:"free_mb"` SharedMB int `json:"shared_mb"` SlotsTotal int `json:"slots_total"` SlotsUsed int `json:"slots_used"` MaxCapacityMB int `json:"max_capacity_mb"` ECC bool `json:"ecc"` Layout []MemoryModule `json:"layout"` } type MemoryModule struct { Slot string `json:"slot"` SizeMB int `json:"size_mb"` Type string `json:"type"` SpeedMHz int `json:"speed_mhz"` ConfiguredMHz int `json:"configured_speed_mhz"` FormFactor string `json:"form_factor"` TypeDetail string `json:"type_detail"` Rank string `json:"rank"` Manufacturer string `json:"manufacturer"` PartNumber string `json:"part_number"` } type GPUDetails struct { Vendor string `json:"vendor"` Model string `json:"model"` DriverVersion string `json:"driver_version"` MemoryDedicatedMB *int `json:"memory_dedicated_mb"` MemorySharedMB *int `json:"memory_shared_mb"` APISupport []string `json:"api_support"` } type StorageDetails struct { Devices []StorageDevice `json:"devices"` Partitions []PartitionInfo `json:"partitions"` } type StorageDevice struct { Name string `json:"name"` Type string `json:"type"` Interface string `json:"interface"` CapacityGB float64 `json:"capacity_gb"` Vendor string `json:"vendor"` Model string `json:"model"` Serial string `json:"serial"` SmartHealth string `json:"smart_health"` Temperature int `json:"temperature_c"` } type PartitionInfo struct { Name string `json:"name"` MountPoint string `json:"mount_point"` FSType string `json:"fs_type"` TotalGB float64 `json:"total_gb"` UsedGB *float64 `json:"used_gb"` FreeGB *float64 `json:"free_gb"` } type PCIInfo struct { Slot string `json:"slot"` Class string `json:"class"` Vendor string `json:"vendor"` Device string `json:"device"` } type USBInfo struct { Bus string `json:"bus"` Device string `json:"device"` VendorID string `json:"vendor_id"` ProductID string `json:"product_id"` Name string `json:"name"` } type NetworkShare struct { Protocol string `json:"protocol"` Source string `json:"source"` MountPoint string `json:"mount_point"` FSType string `json:"fs_type"` Options string `json:"options"` TotalGB *float64 `json:"total_gb"` UsedGB *float64 `json:"used_gb"` FreeGB *float64 `json:"free_gb"` } type NetworkDetails struct { Interfaces []NetworkInterface `json:"interfaces"` } type NetworkInterface struct { Name string `json:"name"` Type string `json:"type"` MAC string `json:"mac"` IP string `json:"ip"` SpeedMbps *int `json:"speed_mbps"` Driver string `json:"driver"` SSID string `json:"ssid"` WakeOnLAN bool `json:"wake_on_lan"` } type MotherboardInfo struct { Vendor string `json:"vendor"` Model string `json:"model"` BIOSVendor string `json:"bios_vendor"` BIOSVersion string `json:"bios_version"` BIOSDate string `json:"bios_date"` } type OSInfo struct { Name string `json:"name"` Version string `json:"version"` KernelVersion string `json:"kernel_version"` Architecture string `json:"architecture"` SessionType string `json:"session_type"` DisplayServer string `json:"display_server"` ScreenResolution string `json:"screen_resolution"` LastBootTime string `json:"last_boot_time"` UptimeSeconds float64 `json:"uptime_seconds"` BatteryPercentage *int `json:"battery_percentage"` BatteryStatus string `json:"battery_status"` BatteryHealth string `json:"battery_health"` Hostname string `json:"hostname"` VirtualizationType string `json:"virtualization_type"` DesktopEnvironment string `json:"desktop_environment"` } type SensorInfo struct { CPUTempC *float64 `json:"cpu_temp_c"` DiskTempsC map[string]float64 `json:"disk_temps_c"` Sensors []SensorReading `json:"sensors"` } type SensorReading struct { Chip string `json:"chip"` Name string `json:"name"` Type string `json:"type"` Value float64 `json:"value"` Unit string `json:"unit"` Alarm bool `json:"alarm"` } type AudioInfo struct { Hardware AudioHardware `json:"hardware"` Software AudioSoftware `json:"software"` } type AudioHardware struct { PCIAudioDevices []string `json:"pci_audio_devices"` AlsaPlayback string `json:"alsa_playback"` AlsaCapture string `json:"alsa_capture"` PCMDevices []string `json:"pcm_devices"` } type AudioSoftware struct { Backend string `json:"backend"` ServerName string `json:"server_name"` ServerVersion string `json:"server_version"` DefaultSink *string `json:"default_sink"` DefaultSource *string `json:"default_source"` Sinks []string `json:"sinks"` Sources []string `json:"sources"` } type Results struct { CPU CPUResult `json:"cpu"` Memory MemResult `json:"memory"` Disk DiskResult `json:"disk"` Network NetResult `json:"network"` GPU GPUResult `json:"gpu"` GlobalScore float64 `json:"global_score"` } type CPUResult struct { EventsPerSec float64 `json:"events_per_sec"` EventsPerSecSingle float64 `json:"events_per_sec_single"` EventsPerSecMulti float64 `json:"events_per_sec_multi"` DurationSec float64 `json:"duration_s"` Score float64 `json:"score"` ScoreSingle float64 `json:"score_single"` ScoreMulti float64 `json:"score_multi"` } type MemResult struct { Throughput float64 `json:"throughput_mib_s"` Score float64 `json:"score"` } type DiskResult struct { ReadMBs float64 `json:"read_mb_s"` WriteMBs float64 `json:"write_mb_s"` IOPSRead float64 `json:"iops_read"` IOPSWrite float64 `json:"iops_write"` LatencyMs float64 `json:"latency_ms"` Score float64 `json:"score"` } type DirectDiskMetrics struct { WriteMBs float64 ReadMBs float64 } type CacheSummary struct { Sizes map[int]int Counts map[int]int } type NetResult struct { Upload float64 `json:"upload_mbps"` Download float64 `json:"download_mbps"` PingMs float64 `json:"ping_ms"` JitterMs *float64 `json:"jitter_ms"` PacketLossPct *float64 `json:"packet_loss_percent"` Score float64 `json:"score"` } type GPUResult struct { GLMark2Score *float64 `json:"glmark2_score"` Score *float64 `json:"score"` } // ========================================== // 2. GLOBALS & UTILS // ========================================== var ( cfg Config debug = false dryRun = false ) const benchScriptVersion = "1.6.6" const benchClientVersion = "1.0.2" var ( sensorInitOnce sync.Once sensorInitErr error ) var ( sizeValueRegex = regexp.MustCompile(`(?i)^([\d\.]+)\s*([kmgtp]i?)?b?$`) quotedStringRegexp = regexp.MustCompile(`"([^"]+)"`) sysbenchEventsRegex = regexp.MustCompile(`events per second:\s+([\d\.]+)`) sysbenchTimeRegex = regexp.MustCompile(`total time:\s+([\d\.]+)s`) ) var ( debugLogFile *os.File debugLogOnce sync.Once debugLogErr error debugLogMu sync.Mutex ) const debugLogFilename = "debug.log" func debugf(format string, args ...interface{}) { if debug { message := fmt.Sprintf("[DEBUG] "+format+"\n", args...) fmt.Print(message) if err := appendDebugLog(message); err != nil { fmt.Printf("[WARN] impossible d'écrire dans %s: %v\n", debugLogFilename, err) } } } // safeRun exécute une commande avec timeout et gestion d'erreur func safeRun(ctx context.Context, name string, args ...string) (string, error) { if debug { debugf("Exec: %s %v", name, args) } if dryRun { return "DRY RUN OUTPUT", nil } cmd := exec.CommandContext(ctx, name, args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { return "", fmt.Errorf("%w: %s", err, stderr.String()) } return stdout.String(), nil } func appendDebugLog(message string) error { if err := ensureDebugLog(); err != nil { return err } debugLogMu.Lock() defer debugLogMu.Unlock() _, err := debugLogFile.WriteString(message) return err } func ensureDebugLog() error { debugLogOnce.Do(func() { wd, err := os.Getwd() if err != nil { debugLogErr = err return } path := filepath.Join(wd, debugLogFilename) file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o777) if err != nil { debugLogErr = err return } if err := file.Chmod(0o777); err != nil { // ignore error } debugLogFile = file }) return debugLogErr } func closeDebugLog() { if debugLogFile != nil { _ = debugLogFile.Close() } } func collectOSInfo(ctx context.Context) OSInfo { info := OSInfo{} if h, err := host.InfoWithContext(ctx); err == nil { info.Name = h.Platform info.Version = h.PlatformVersion info.KernelVersion = h.KernelVersion info.Architecture = h.KernelArch info.Hostname = h.Hostname info.VirtualizationType = h.VirtualizationSystem if h.VirtualizationSystem != "" && h.VirtualizationRole != "" { info.VirtualizationType = fmt.Sprintf("%s-%s", h.VirtualizationSystem, h.VirtualizationRole) } info.UptimeSeconds = float64(h.Uptime) } if host, err := os.Hostname(); err == nil { info.Hostname = host } if data, err := os.ReadFile("/etc/os-release"); err == nil { for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := parts[0] value := strings.Trim(parts[1], "\"") switch key { case "NAME": info.Name = value case "VERSION": info.Version = value } } } if out, err := safeRun(ctx, "uname", "-r"); err == nil { info.KernelVersion = strings.TrimSpace(out) } if out, err := safeRun(ctx, "uname", "-m"); err == nil { info.Architecture = strings.TrimSpace(out) } if vt, err := safeRun(ctx, "systemd-detect-virt"); err == nil { vt = strings.TrimSpace(vt) if vt == "none" { info.VirtualizationType = "none" } else if vt != "" { info.VirtualizationType = vt } } info.SessionType = os.Getenv("XDG_SESSION_TYPE") if info.SessionType == "" { if sessionID := os.Getenv("XDG_SESSION_ID"); sessionID != "" { if out, err := safeRun(ctx, "loginctl", "show-session", sessionID, "-p", "Type"); err == nil { info.SessionType = strings.TrimPrefix(strings.TrimSpace(out), "Type=") } } } info.DisplayServer = info.SessionType info.DesktopEnvironment = os.Getenv("XDG_CURRENT_DESKTOP") if out, err := safeRun(ctx, "xrandr"); err == nil { if strings.Contains(out, "current") { if parts := strings.Split(out, "\n")[0]; strings.Contains(parts, "current") { fields := strings.Split(parts, ",") for _, f := range fields { f = strings.TrimSpace(f) if strings.HasPrefix(f, "current") { info.ScreenResolution = strings.TrimPrefix(f, "current ") break } } } } } if out, err := safeRun(ctx, "xrandr", "--current"); err == nil && info.ScreenResolution == "" { for _, line := range strings.Split(out, "\n") { if strings.Contains(line, "*") && strings.Contains(line, "+") { info.ScreenResolution = strings.TrimSpace(strings.Fields(line)[0]) break } } } if out, err := safeRun(ctx, "who", "-b"); err == nil { info.LastBootTime = strings.TrimSpace(strings.ReplaceAll(out, "system boot", "")) } if uptimeRaw, err := os.ReadFile("/proc/uptime"); err == nil { if parts := strings.Fields(string(uptimeRaw)); len(parts) > 0 { if val, err := strconv.ParseFloat(parts[0], 64); err == nil { info.UptimeSeconds = val } } } info.BatteryPercentage = parseBatteryInt("/sys/class/power_supply/BAT0/capacity") info.BatteryStatus = parseBatteryString("/sys/class/power_supply/BAT0/status") info.BatteryHealth = parseBatteryString("/sys/class/power_supply/BAT0/health") return info } func parseBatteryInt(path string) *int { if data, err := os.ReadFile(path); err == nil { if val, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { return &val } } return nil } func parseBatteryString(path string) string { if data, err := os.ReadFile(path); err == nil { return strings.TrimSpace(string(data)) } return "" } func collectCPUInfo(ctx context.Context) CPUDetails { info := CPUDetails{} cpuInfos, err := cpu.InfoWithContext(ctx) if err != nil || len(cpuInfos) == 0 { return info } first := cpuInfos[0] info.Vendor = first.VendorID info.Model = first.ModelName if cores, err := cpu.CountsWithContext(ctx, false); err == nil && cores > 0 { info.Cores = cores } else if int(first.Cores) > 0 { info.Cores = int(first.Cores) } if info.Cores == 0 { info.Cores = len(cpuInfos) } if threads, err := cpu.CountsWithContext(ctx, true); err == nil && threads > 0 { info.Threads = threads } else { info.Threads = len(cpuInfos) } info.Flags = first.Flags if lscpuOut, err := safeRun(ctx, "lscpu"); err == nil { l1, l2, l3 := parseLscpuCacheSizes(lscpuOut) if l1 > 0 { info.CacheL1KB = l1 } if l2 > 0 { info.CacheL2KB = l2 } if l3 > 0 { info.CacheL3KB = l3 } } cacheSummary := collectCacheSizes(ctx, info.Cores) if info.CacheL1KB == 0 && cacheSummary.Sizes[1] > 0 { info.CacheL1KB = cacheSummary.Sizes[1] } if info.CacheL2KB == 0 && cacheSummary.Sizes[2] > 0 { info.CacheL2KB = cacheSummary.Sizes[2] } if info.CacheL3KB == 0 && cacheSummary.Sizes[3] > 0 { info.CacheL3KB = cacheSummary.Sizes[3] } applyCpuidCacheInfo(&info, cacheSummary) info.Microarchitecture = detectMicroarchitecture(first.ModelName, first.Family) info.BaseFreqGHz, info.MaxFreqGHz = determineCPUFreqs(ctx, first) return info } func collectCacheSizes(ctx context.Context, physicalCores int) CacheSummary { cacheDir := "/sys/devices/system/cpu/cpu0/cache" entries, err := os.ReadDir(cacheDir) summary := CacheSummary{ Sizes: map[int]int{}, Counts: map[int]int{}, } if err != nil { return summary } for _, entry := range entries { if !strings.HasPrefix(entry.Name(), "index") { continue } levelPath := filepath.Join(cacheDir, entry.Name(), "level") level := readIntFromFile(levelPath) if level <= 0 { continue } sizePath := filepath.Join(cacheDir, entry.Name(), "size") sizeKB := parseCacheSize(readStringFromFile(sizePath)) if sizeKB > 0 { summary.Sizes[level] += sizeKB summary.Counts[level]++ } } return summary } func applyCpuidCacheInfo(info *CPUDetails, summary CacheSummary) { cache := cpuid.CPU.Cache if cache.L1I < 0 && cache.L1D < 0 && cache.L2 < 0 && cache.L3 < 0 { return } cores := info.Cores if cores <= 0 { cores = cpuid.CPU.PhysicalCores if cores <= 0 { cores = 1 } } if summary.Counts == nil { summary.Counts = map[int]int{} } l1Bytes := 0 if cache.L1I > 0 { l1Bytes += cache.L1I } if cache.L1D > 0 { l1Bytes += cache.L1D } if l1Bytes > 0 && info.CacheL1KB == 0 { info.CacheL1KB = int(int64(l1Bytes) * int64(cores) / 1024) } if cache.L2 > 0 && info.CacheL2KB == 0 { l2Slices := summary.Counts[2] if l2Slices <= 0 { l2Slices = cores } info.CacheL2KB = maxInt(info.CacheL2KB, int((int64(cache.L2)*int64(l2Slices))/1024)) } if cache.L3 > 0 && info.CacheL3KB == 0 { l3Slices := summary.Counts[3] if l3Slices <= 0 { l3Slices = 1 } info.CacheL3KB = maxInt(info.CacheL3KB, int((int64(cache.L3)*int64(l3Slices))/1024)) } } func parseLscpuCacheSizes(output string) (l1, l2, l3 int) { l1d := parseCacheValue(output, "L1d") l1i := parseCacheValue(output, "L1i") l2 = parseCacheValue(output, "L2") l3 = parseCacheValue(output, "L3") if l1d > 0 || l1i > 0 { l1 = l1d + l1i } return } func parseCacheValue(output, label string) int { re := regexp.MustCompile(fmt.Sprintf(`(?m)^Cache\s+%s\s*:\s*([\d\.]+)\s*([KMGT]iB?|B|K|M|G)`, regexp.QuoteMeta(label))) matches := re.FindStringSubmatch(output) if len(matches) != 3 && len(matches) != 4 { return 0 } value := matches[1] unit := matches[len(matches)-1] num, err := strconv.ParseFloat(value, 64) if err != nil { return 0 } switch strings.ToUpper(unit) { case "KIB", "KB", "K": return int(num) case "MIB", "MB", "M": return int(num * 1024) case "GIB", "GB", "G": return int(num * 1024 * 1024) default: return int(num) } } var ( dmidecodeCurrentSpeedRE = regexp.MustCompile(`(?m)^Current Speed:\s+([\d\.]+)\s+MHz`) dmidecodeMaxSpeedRE = regexp.MustCompile(`(?m)^Max Speed:\s+([\d\.]+)\s+MHz`) microarchitectureMap = map[string]string{ "25": "Zen", } ) func detectMicroarchitecture(modelName string, family string) string { if strings.Contains(strings.ToLower(modelName), "ryzen") { return "Zen" } if value, ok := microarchitectureMap[family]; ok { return value } if family != "" { return family } return "" } func determineCPUFreqs(ctx context.Context, info cpu.InfoStat) (float64, float64) { base := info.Mhz / 1000 max := info.Mhz / 1000 if val := readFrequencyFromFile("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq"); val > 0 { max = float64(val) / 1_000_000 } if val := readFrequencyFromFile("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq"); val > 0 && base == 0 { base = float64(val) / 1_000_000 } if out, err := safeRun(ctx, "dmidecode", "-t", "4"); err == nil { if curSpeed := parseDMIDecodeSpeed(out, dmidecodeCurrentSpeedRE); curSpeed > 0 { base = curSpeed / 1000 } if maxSpeed := parseDMIDecodeSpeed(out, dmidecodeMaxSpeedRE); maxSpeed > 0 { max = maxSpeed / 1000 } } return round2(base), round2(max) } func parseDMIDecodeSpeed(output string, re *regexp.Regexp) float64 { if match := re.FindStringSubmatch(output); len(match) == 2 { if val, err := strconv.ParseFloat(match[1], 64); err == nil { return val } } return 0 } func readFrequencyFromFile(path string) int64 { data, err := os.ReadFile(path) if err != nil { return 0 } val := strings.TrimSpace(string(data)) if parsed, err := strconv.ParseInt(val, 10, 64); err == nil { return parsed } return 0 } func parseCacheSize(value string) int { value = strings.TrimSpace(value) if value == "" { return 0 } units := []struct { suffix string multiplier int }{ {"KIB", 1}, {"KB", 1}, {"K", 1}, {"MIB", 1024}, {"MB", 1024}, {"M", 1024}, {"GIB", 1024 * 1024}, {"GB", 1024 * 1024}, {"G", 1024 * 1024}, } upper := strings.ToUpper(value) multiplier := 1 for _, unit := range units { if strings.HasSuffix(upper, unit.suffix) { value = strings.TrimSpace(value[:len(value)-len(unit.suffix)]) multiplier = unit.multiplier break } } if value == "" { return 0 } num, err := strconv.ParseFloat(value, 64) if err != nil { return 0 } return int(num * float64(multiplier)) } func readIntFromFile(path string) int { value := readStringFromFile(path) if value == "" { return 0 } if parsed, err := strconv.Atoi(value); err == nil { return parsed } return 0 } func readStringFromFile(path string) string { data, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(data)) } func collectRAMInfo(ctx context.Context) RAMDetails { res := RAMDetails{} if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { res.TotalMB = int(vm.Total / (1024 * 1024)) res.UsedMB = int(vm.Used / (1024 * 1024)) res.FreeMB = int(vm.Available / (1024 * 1024)) res.SharedMB = int(vm.Shared / (1024 * 1024)) } slotsTotal, slotsUsed, maxCapacity, ecc, layout := collectSMBIOSMemory() if len(layout) > 0 { correctSlots := len(layout) if slotsTotal == 0 || slotsTotal < correctSlots || slotsTotal > correctSlots*4 { debugf("Correction RAM slots_total=%d => %d (layout=%d)", slotsTotal, correctSlots, len(layout)) slotsTotal = correctSlots } if slotsUsed == 0 { slotsUsed = len(layout) } } if slotsTotal > 0 { res.SlotsTotal = slotsTotal } if slotsUsed > 0 { res.SlotsUsed = slotsUsed } if maxCapacity > 0 { res.MaxCapacityMB = maxCapacity } res.ECC = ecc res.Layout = layout if res.MaxCapacityMB == 0 { debugf("RAM fallback max capacity: SMBIOS=0 => utiliser total=%dMB", res.TotalMB) res.MaxCapacityMB = res.TotalMB } if res.SlotsTotal == 0 && len(layout) > 0 { debugf("RAM fallback slots total: SMBIOS=0 => %d modules détectés", len(layout)) res.SlotsTotal = len(layout) } if res.SlotsUsed == 0 && len(layout) > 0 { debugf("RAM fallback slots used: SMBIOS=0 => %d modules détectés", len(layout)) res.SlotsUsed = len(layout) } debugf("RAM result: max=%dMB slotsTotal=%d slotsUsed=%d layout=%d modules", res.MaxCapacityMB, res.SlotsTotal, res.SlotsUsed, len(layout)) return res } const smbiosTableTypePhysicalMemoryArray smbios.TableType = 16 func collectSMBIOSMemory() (slotsTotal, slotsUsed, maxCapacityMB int, ecc bool, layout []MemoryModule) { info, err := smbios.FromSysfs() if err != nil { debugf("Lecture SMBIOS impossible : %v", err) return } debugf("SMBIOS détecté : %s", info) slotsTotal, ecc, maxCapacityMB = parseSMBIOSPhysicalMemoryArray(info) modules, err := info.GetMemoryDevices() if err != nil { debugf("Impossible de lire les modules mémoire SMBIOS : %v", err) return } for _, md := range modules { module := MemoryModule{ Slot: md.DeviceLocator, Type: md.Type.String(), TypeDetail: md.TypeDetail.String(), FormFactor: md.FormFactor.String(), Manufacturer: md.Manufacturer, PartNumber: md.PartNumber, SpeedMHz: int(md.Speed), ConfiguredMHz: int(md.ConfiguredSpeed), Rank: memoryRank(md.Attributes), } if size := md.GetSizeBytes(); size > 0 { module.SizeMB = int(size / (1024 * 1024)) slotsUsed++ } debugf("Module SMBIOS détecté : %s %+v", module.Slot, module) layout = append(layout, module) } if slotsTotal == 0 { slotsTotal = len(layout) } if slotsUsed == 0 { slotsUsed = len(layout) } debugf("Mémoire SMBIOS : slots_total=%d slots_used=%d ecc=%t max=%dMB", slotsTotal, slotsUsed, ecc, maxCapacityMB) return } func parseSMBIOSPhysicalMemoryArray(info *smbios.Info) (slotsTotal int, ecc bool, maxCapacityMB int) { for _, table := range info.Tables { if table.Type != smbiosTableTypePhysicalMemoryArray { continue } if val, err := table.GetByteAt(13); err == nil && val > 0 { debugf("[SMBIOS RAW] Handle=%s slots_byte=0x%X (%d)", table.Handle, val, val) slotsTotal = int(val) } if val, err := table.GetByteAt(6); err == nil { ecc = val != 0 && val != 3 } if dw, err := table.GetDWordAt(7); err == nil { if dw == 0x80000000 && table.Len() >= 0x10 { if ext, err := table.GetDWordAt(12); err == nil && ext > 0 { maxCapacityMB = int(ext / (1024 * 1024)) } } else if dw > 0 { maxCapacityMB = int(dw / 1024) } } break } return } func memoryRank(attrs uint8) string { rank := attrs & 0x0f if rank == 0 { return "Unknown" } return strconv.Itoa(int(rank)) } func collectGPUInfo(ctx context.Context) GPUDetails { info := GPUDetails{} if out, err := safeRun(ctx, "lspci"); err == nil { for _, line := range strings.Split(out, "\n") { if strings.Contains(line, "VGA compatible controller") || strings.Contains(line, "3D controller") { parts := strings.SplitN(line, ":", 3) if len(parts) >= 3 { desc := strings.TrimSpace(parts[2]) info.Model = desc info.Vendor = strings.Split(desc, " ")[0] break } } } } if out, err := safeRun(ctx, "nvidia-smi", "--query-gpu=name,driver_version,memory.total", "--format=csv,noheader,nounits"); err == nil { lines := strings.Split(strings.TrimSpace(out), "\n") if len(lines) > 0 { fields := strings.Split(lines[0], ",") if len(fields) >= 3 { info.Model = strings.TrimSpace(fields[0]) info.DriverVersion = strings.TrimSpace(fields[1]) if v := atoi(strings.TrimSpace(fields[2])); v > 0 { info.MemoryDedicatedMB = &v } } } } return info } func collectStorageInfo(ctx context.Context) StorageDetails { detail := StorageDetails{} partitions, err := disk.PartitionsWithContext(ctx, true) if err != nil { return detail } deviceMap := map[string]*StorageDevice{} for _, part := range partitions { if shouldSkipPartition(part) { continue } var usage *disk.UsageStat if part.Mountpoint != "" { if u, err := disk.UsageWithContext(ctx, part.Mountpoint); err == nil { usage = u } } record := PartitionInfo{ Name: part.Device, MountPoint: part.Mountpoint, FSType: part.Fstype, TotalGB: bytesToGB(int64(usageTotal(usage))), } if usage != nil { record.UsedGB = floatPtr(bytesToGB(int64(usage.Used))) record.FreeGB = floatPtr(bytesToGB(int64(usage.Free))) } detail.Partitions = append(detail.Partitions, record) devName := deriveDeviceRoot(part.Device) if !isPhysicalDevice(devName) { continue } if _, ok := deviceMap[devName]; !ok { deviceMap[devName] = &StorageDevice{ Name: devName, Type: guessDiskType(devName), } } if usage != nil { deviceMap[devName].CapacityGB = bytesToGB(int64(usage.Total)) } } populateDevicesFromLsblk(ctx, deviceMap) for _, dev := range deviceMap { annotateSmartDevice(dev) } if len(deviceMap) > 0 { names := make([]string, 0, len(deviceMap)) for name := range deviceMap { names = append(names, name) } sort.Strings(names) for _, name := range names { detail.Devices = append(detail.Devices, *deviceMap[name]) } } return detail } func usageTotal(stats *disk.UsageStat) int64 { if stats == nil { return 0 } return int64(stats.Total) } func guessDiskType(device string) string { lower := strings.ToLower(device) switch { case strings.Contains(lower, "nvme"): return "ssd" case strings.Contains(lower, "sd"): return "ssd" case strings.Contains(lower, "hd"): return "hdd" default: return "disk" } } func populateDevicesFromLsblk(ctx context.Context, deviceMap map[string]*StorageDevice) { if len(deviceMap) == 0 { // still want to capture drives even if no partitions were added } raw, err := safeRun(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,TRAN,VENDOR,MODEL,SERIAL,SIZE") if err != nil { debugf("lsblk JSON échoue: %v", err) return } var output lsblkOutput if err := json.Unmarshal([]byte(raw), &output); err != nil { debugf("lsblk JSON parse échoue: %v", err) return } for _, entry := range output.BlockDevices { addLsblkDevice(entry, deviceMap) } } func addLsblkDevice(entry lsblkDevice, deviceMap map[string]*StorageDevice) { if entry.Type == "" { for _, child := range entry.Children { addLsblkDevice(child, deviceMap) } return } if entry.Type == "disk" { path := "/dev/" + entry.Name if !isPhysicalDevice(path) || isIgnoredDevice(path) { return } dev := deviceMap[path] if dev == nil { dev = &StorageDevice{Name: path} deviceMap[path] = dev } if entry.Tran != "" { dev.Interface = strings.ToLower(entry.Tran) } if entry.Vendor != "" { dev.Vendor = entry.Vendor } if entry.Model != "" { dev.Model = entry.Model } if entry.Serial != "" { dev.Serial = entry.Serial } if sizeBytes, err := entry.Size.Int64(); err == nil && sizeBytes > 0 { dev.CapacityGB = float64(sizeBytes) / (1024 * 1024 * 1024) } } for _, child := range entry.Children { addLsblkDevice(child, deviceMap) } } type lsblkOutput struct { BlockDevices []lsblkDevice `json:"blockdevices"` } type lsblkDevice struct { Name string `json:"name"` Type string `json:"type"` Tran string `json:"tran"` Vendor string `json:"vendor"` Model string `json:"model"` Serial string `json:"serial"` Size json.Number `json:"size"` Children []lsblkDevice `json:"children"` } func deriveDeviceRoot(device string) string { if device == "" { return device } root := device if strings.HasPrefix(device, "/dev/") { if idx := strings.LastIndex(device, "p"); idx > strings.LastIndex(device, "/") { if _, err := strconv.Atoi(device[idx+1:]); err == nil { candidate := device[:idx] if _, err := os.Stat(candidate); err == nil { return candidate } } } candidate := strings.TrimRightFunc(device, unicode.IsDigit) if candidate != "" && candidate != device { if _, err := os.Stat(candidate); err == nil { return candidate } } } return root } func shouldSkipPartition(part disk.PartitionStat) bool { if part.Device == "" { return true } lowerFs := strings.ToLower(part.Fstype) if lowerFs != "" { if _, ok := pseudoFSTypes[lowerFs]; ok { return true } } if strings.Contains(lowerFs, "nsfs") { return true } if part.Mountpoint != "" { switch { case strings.HasPrefix(part.Mountpoint, "/run/docker"), strings.HasPrefix(part.Mountpoint, "/sys"), strings.HasPrefix(part.Mountpoint, "/proc"), strings.HasPrefix(part.Mountpoint, "/dev"): return true } } if isIgnoredDevice(part.Device) { return true } return false } func isPhysicalDevice(device string) bool { device = strings.TrimSpace(device) if device == "" || !strings.HasPrefix(device, "/dev/") { return false } normalized := strings.TrimPrefix(device, "/dev/") normalized = strings.TrimSuffix(normalized, "p") lower := strings.ToLower(normalized) physicalPrefixes := []string{"sd", "hd", "vd", "nvme", "mmcblk", "xvd", "mapper", "dm-"} for _, prefix := range physicalPrefixes { if strings.HasPrefix(lower, prefix) { return true } } return false } func isIgnoredDevice(device string) bool { lower := strings.ToLower(strings.TrimSpace(device)) ignored := []string{"/dev/loop", "/dev/ram", "/dev/fd", "/dev/sr", "/dev/mapper", "/dev/nbd", "none"} for _, prefix := range ignored { if strings.HasPrefix(lower, prefix) { return true } } return false } type mountEntry struct { MountPoint string FSName string Source string } func isPseudoMountType(name string) bool { _, ok := mountPseudoTypes[strings.ToLower(name)] return ok } func chooseBenchDir() (string, mountEntry) { mounts := parseProcMounts() candidates := []string{cfg.Runtime.TempDir, "/var/tmp", os.TempDir()} var fallback mountEntry for _, base := range candidates { if base == "" { continue } absPath, err := filepath.Abs(base) if err != nil { continue } if err := os.MkdirAll(absPath, 0o755); err != nil { continue } entry := findMountForPath(absPath, mounts) if entry.MountPoint != "" { if !isPseudoMountType(entry.FSName) && isPhysicalMount(entry) { bench := filepath.Join(absPath, "bench-client") if err := os.MkdirAll(bench, 0o755); err != nil { continue } return bench, entry } if fallback.MountPoint == "" { fallback = entry } } } for _, entry := range mounts { if entry.MountPoint == "" || isPseudoMountType(entry.FSName) { continue } if !isPhysicalMount(entry) { if fallback.MountPoint == "" { fallback = entry } continue } bench := filepath.Join(entry.MountPoint, "bench-client") if err := os.MkdirAll(bench, 0o755); err != nil { continue } return bench, entry } fallbackDir := filepath.Join(os.TempDir(), "bench-client") _ = os.MkdirAll(fallbackDir, 0o755) return fallbackDir, fallback } func parseProcMounts() []mountEntry { data, err := os.ReadFile("/proc/mounts") if err != nil { return nil } lines := strings.Split(string(data), "\n") entries := make([]mountEntry, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } fields := strings.Fields(line) if len(fields) < 3 { continue } entries = append(entries, mountEntry{ Source: decodeMountField(fields[0]), MountPoint: decodeMountField(fields[1]), FSName: fields[2], }) } sort.Slice(entries, func(i, j int) bool { return len(entries[i].MountPoint) > len(entries[j].MountPoint) }) return entries } func findMountForPath(path string, entries []mountEntry) mountEntry { path = filepath.Clean(path) var best mountEntry bestLen := -1 for _, entry := range entries { mp := entry.MountPoint if mp == "" { continue } if mp != "/" && !strings.HasPrefix(path, mp) { continue } if len(mp) > bestLen { best = entry bestLen = len(mp) } } return best } func isPhysicalMount(entry mountEntry) bool { return entry.Source != "" && isPhysicalDevice(entry.Source) } func decodeMountField(field string) string { return strings.ReplaceAll(field, "\\040", " ") } var pseudoFSTypes = map[string]struct{}{ "tmpfs": {}, "sysfs": {}, "proc": {}, "devtmpfs": {}, "cgroup": {}, "cgroup2": {}, "devpts": {}, "securityfs": {}, "squashfs": {}, "overlay": {}, } var mountPseudoTypes = map[string]struct{}{ "tmpfs": {}, "overlay": {}, "squashfs": {}, "ramfs": {}, "devtmpfs": {}, "cgroup": {}, "cgroup2": {}, "devpts": {}, "securityfs": {}, "tracefs": {}, "fusectl": {}, "debugfs": {}, } func annotateSmartDevice(dev *StorageDevice) { if dev == nil || dev.Name == "" { return } handle, err := smartgo.Open(dev.Name) if err != nil { debugf("SMART open %s impossible: %v", dev.Name, err) dev.SmartHealth = "UNAVAILABLE" return } defer handle.Close() health := "UNKNOWN" if attrs, err := handle.ReadGenericAttributes(); err == nil { health = "PASSED" if attrs.Temperature > 0 { dev.Temperature = int(attrs.Temperature) } } else { debugf("SMART lecture générique %s échouée: %v", dev.Name, err) health = fmt.Sprintf("FAILED: %v", err) } var vendor, model, serial string switch specific := handle.(type) { case *smartgo.SataDevice: if id, err := specific.Identify(); err == nil { model = strings.TrimSpace(id.ModelNumber()) serial = strings.TrimSpace(id.SerialNumber()) if vendor == "" { vendor = guessVendorFromModel(model) } } else { debugf("SMART Identify SATA %s échoué: %v", dev.Name, err) } case *smartgo.NVMeDevice: if ctrl, _, err := specific.Identify(); err == nil { model = strings.TrimSpace(ctrl.ModelNumber()) serial = strings.TrimSpace(ctrl.SerialNumber()) if vendor == "" { vendor = guessVendorFromModel(model) } } else { debugf("SMART Identify NVMe %s échoué: %v", dev.Name, err) } case *smartgo.ScsiDevice: if inq, err := specific.Inquiry(); err == nil { vendor = strings.TrimSpace(string(bytes.TrimSpace(inq.VendorIdent[:]))) model = strings.TrimSpace(string(bytes.TrimSpace(inq.ProductIdent[:]))) } else { debugf("SMART Inquiry SCSI %s échouée: %v", dev.Name, err) } if serialNum, err := specific.SerialNumber(); err == nil && serialNum != "" { serial = strings.TrimSpace(serialNum) } else if err != nil { debugf("SMART Serial SCSI %s échouée: %v", dev.Name, err) } default: debugf("SMART type inconnu pour %s", dev.Name) } if vendor != "" && dev.Vendor == "" { dev.Vendor = vendor } if model != "" { dev.Model = model } if serial != "" { dev.Serial = serial } dev.SmartHealth = health } func guessVendorFromModel(model string) string { if model == "" { return "" } fields := strings.Fields(model) if len(fields) == 0 { return "" } return fields[0] } func bytesToGB(value int64) float64 { return float64(value) / (1024 * 1024 * 1024) } func collectPCIDevices(ctx context.Context) []PCIInfo { devices := []PCIInfo{} out, err := safeRun(ctx, "lspci", "-mm") if err != nil { return devices } for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } slot := "" rest := line if idx := strings.Index(line, " "); idx >= 0 { slot = line[:idx] rest = line[idx+1:] } slot = strings.TrimSpace(slot) if slot == "" { continue } matches := quotedStringRegexp.FindAllString(rest, -1) if len(matches) < 3 { continue } class := strings.Trim(matches[0], `"`) vendor := strings.Trim(matches[1], `"`) device := strings.Trim(matches[2], `"`) devices = append(devices, PCIInfo{ Slot: slot, Class: class, Vendor: vendor, Device: device, }) } return devices } func collectUSBDevices(ctx context.Context) []USBInfo { var devices []USBInfo out, err := safeRun(ctx, "lsusb") if err != nil { return devices } for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Fields(line) if len(parts) < 6 { continue } bus := parts[1] device := strings.TrimSuffix(parts[3], ":") idParts := strings.Split(parts[5], ":") vendorID := "" productID := "" if len(idParts) == 2 { vendorID = idParts[0] productID = idParts[1] } name := strings.Join(parts[6:], " ") devices = append(devices, USBInfo{ Bus: bus, Device: device, VendorID: vendorID, ProductID: productID, Name: name, }) } return devices } func collectNetworkShares(ctx context.Context) []NetworkShare { shares := []NetworkShare{} data, err := os.ReadFile("/proc/mounts") if err != nil { return shares } for _, line := range strings.Split(string(data), "\n") { if line = strings.TrimSpace(line); line == "" { continue } parts := strings.Fields(line) if len(parts) < 6 { continue } fs := strings.ToLower(parts[2]) if !isNetworkFSType(fs) { continue } share := NetworkShare{ Protocol: fs, Source: parts[0], MountPoint: parts[1], FSType: fs, Options: parts[3], } if sz, used, free := dfStats(ctx, parts[1]); sz > 0 { share.TotalGB = floatPtr(sz) if used != 0 { share.UsedGB = floatPtr(used) } if free != 0 { share.FreeGB = floatPtr(free) } } shares = append(shares, share) } return shares } func isNetworkFSType(fs string) bool { networkFS := []string{"nfs", "nfs4", "cifs", "smbfs", "fuse.cifs", "fuse.smbfs", "afp", "afpfs", "fuse.afpfs", "sshfs", "fuse.sshfs", "ftpfs", "curlftpfs", "fuse.ftpfs", "davfs", "davfs2", "fuse.webdavfs"} for _, candidate := range networkFS { if fs == candidate { return true } } return false } func dfStats(ctx context.Context, mountPoint string) (total, used, free float64) { out, err := safeRun(ctx, "df", "-k", mountPoint) if err != nil { return 0, 0, 0 } lines := strings.Split(string(out), "\n") if len(lines) < 2 { return 0, 0, 0 } fields := strings.Fields(lines[1]) if len(fields) < 5 { return 0, 0, 0 } total = kbToGB(atoi(fields[1])) used = kbToGB(atoi(fields[2])) free = kbToGB(atoi(fields[3])) return total, used, free } func kbToGB(value int) float64 { return float64(value) / (1024 * 1024) } func floatPtr(v float64) *float64 { return &v } func collectNetworkInfo(ctx context.Context) NetworkDetails { details := NetworkDetails{} ifaces, err := gopsnet.InterfacesWithContext(ctx) if err != nil { return details } for _, iface := range ifaces { ifaceName := iface.Name if isLoopbackInterface(iface) || isVirtualInterface(ifaceName) { continue } netType := guessNetworkType(ifaceName) if netType != "ethernet" && netType != "wifi" { continue } n := NetworkInterface{ Name: ifaceName, MAC: iface.HardwareAddr, Type: netType, } for _, addr := range iface.Addrs { ip := addr.Addr if idx := strings.Index(ip, "/"); idx >= 0 { ip = ip[:idx] } if ip == "" { continue } n.IP = ip break } speed, driver, wake := getInterfaceStats(ctx, ifaceName) if speed != nil { n.SpeedMbps = speed } n.Driver = driver n.WakeOnLAN = wake if netType == "wifi" { if ssid := getWirelessSSID(ctx, ifaceName); ssid != "" { n.SSID = ssid } } details.Interfaces = append(details.Interfaces, n) } return details } func guessNetworkType(name string) string { switch { case strings.HasPrefix(name, "en"), strings.HasPrefix(name, "eth"): return "ethernet" case strings.HasPrefix(name, "wl"): return "wifi" default: return "unknown" } } func isLoopbackInterface(iface gopsnet.InterfaceStat) bool { for _, flag := range iface.Flags { if strings.EqualFold(flag, "loopback") { return true } } return false } func isVirtualInterface(name string) bool { name = strings.ToLower(name) virtualPrefixes := []string{ "lo", "br-", "docker", "veth", "virbr", "vmnet", "vmbr", "vboxnet", "tap", "tun", "zerotier", "wg", } for _, prefix := range virtualPrefixes { if strings.HasPrefix(name, prefix) { return true } } return false } func getInterfaceStats(ctx context.Context, iface string) (*int, string, bool) { var speed *int driver := "" wake := false if out, err := safeRun(ctx, "ethtool", iface); err == nil { for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "Speed:") { if val := parseSpeedValue(line); val != nil { speed = val } } if strings.HasPrefix(line, "Wake-on:") { wake = parseWakeOnValue(line) } } } if out, err := safeRun(ctx, "ethtool", "-i", iface); err == nil { for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "driver:") { driver = strings.TrimSpace(strings.TrimPrefix(line, "driver:")) break } } } return speed, driver, wake } func parseSpeedValue(line string) *int { if idx := strings.Index(line, ":"); idx >= 0 { value := strings.TrimSpace(line[idx+1:]) value = strings.TrimSuffix(value, "Mb/s") value = strings.TrimSpace(value) if strings.EqualFold(value, "unknown!") || value == "" { return nil } if parsed, err := strconv.Atoi(value); err == nil { return &parsed } } return nil } func parseWakeOnValue(line string) bool { if idx := strings.Index(line, ":"); idx >= 0 { value := strings.TrimSpace(line[idx+1:]) return strings.Contains(strings.ToLower(value), "g") } return false } func getWirelessSSID(ctx context.Context, iface string) string { if out, err := safeRun(ctx, "iwgetid", iface, "-r"); err == nil { if ssid := strings.TrimSpace(out); ssid != "" { return ssid } } if out, err := safeRun(ctx, "iw", iface, "link"); err == nil { for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "SSID:") { return strings.TrimSpace(strings.TrimPrefix(line, "SSID:")) } } } return "" } func collectMotherboardInfo(ctx context.Context) MotherboardInfo { info := MotherboardInfo{} if base, err := safeRun(ctx, "dmidecode", "-t", "2"); err == nil { for _, line := range strings.Split(base, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "Manufacturer:"): info.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Manufacturer:")) case strings.HasPrefix(line, "Product Name:"): info.Model = strings.TrimSpace(strings.TrimPrefix(line, "Product Name:")) } } } if bios, err := safeRun(ctx, "dmidecode", "-t", "0"); err == nil { for _, line := range strings.Split(bios, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "Vendor:"): info.BIOSVendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor:")) case strings.HasPrefix(line, "Version:"): info.BIOSVersion = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) case strings.HasPrefix(line, "Release Date:"): info.BIOSDate = strings.TrimSpace(strings.TrimPrefix(line, "Release Date:")) } } } return info } func collectSensors(ctx context.Context) SensorInfo { info := SensorInfo{ DiskTempsC: make(map[string]float64), Sensors: []SensorReading{}, } if err := ensureSensorsInitialized(); err == nil { if system, err := lmsensors.Get(); err == nil { for chipID, chip := range system.Chips { chipType := strings.ToLower(chip.Type) for _, sensor := range chip.Sensors { reading := SensorReading{ Chip: chipID, Name: sensor.Name, Type: strings.ToLower(sensor.SensorType.String()), Value: sensor.Value, Unit: sensor.Unit, Alarm: sensor.Alarm, } info.Sensors = append(info.Sensors, reading) if reading.Type == "temperature" && info.CPUTempC == nil && isCpuSensor(chipType, reading.Name) { val := reading.Value info.CPUTempC = &val } if reading.Type == "temperature" && isDiskSensor(chipType, reading.Name) { info.DiskTempsC[fmt.Sprintf("%s:%s", chipID, reading.Name)] = reading.Value } } } } else { debugf("lecture go-lmsensors échoue: %v", err) } } else { debugf("initialisation go-lmsensors échoue: %v", err) } fillThermalFallback(&info) return info } func ensureSensorsInitialized() error { sensorInitOnce.Do(func() { sensorInitErr = lmsensors.Init() }) return sensorInitErr } func fillThermalFallback(info *SensorInfo) { if info.DiskTempsC == nil { info.DiskTempsC = make(map[string]float64) } if zones, err := os.ReadDir("/sys/class/thermal"); err == nil { for _, zone := range zones { zonePath := filepath.Join("/sys/class/thermal", zone.Name()) kind, err := os.ReadFile(filepath.Join(zonePath, "type")) if err != nil { continue } kindStr := strings.TrimSpace(string(kind)) tempRaw, err := os.ReadFile(filepath.Join(zonePath, "temp")) if err != nil { continue } tempVal := atoi(strings.TrimSpace(string(tempRaw))) if tempVal == 0 { continue } tempC := float64(tempVal) / 1000 lower := strings.ToLower(kindStr) if info.CPUTempC == nil && (strings.Contains(lower, "package") || strings.Contains(lower, "cpu")) { val := tempC info.CPUTempC = &val } if strings.Contains(lower, "nvme") || strings.Contains(lower, "disk") || strings.Contains(lower, "ssd") { info.DiskTempsC[kindStr] = tempC } } } } func isCpuSensor(chipType, name string) bool { lower := strings.ToLower(name) return strings.Contains(lower, "cpu") || strings.Contains(lower, "package") || strings.Contains(chipType, "core") || strings.Contains(chipType, "cpu") } func isDiskSensor(chipType, name string) bool { lower := strings.ToLower(name) return strings.Contains(lower, "nvme") || strings.Contains(lower, "disk") || strings.Contains(lower, "ssd") || strings.Contains(chipType, "nvme") || strings.Contains(chipType, "sata") } func collectAudioInfo(ctx context.Context) AudioInfo { info := AudioInfo{ Hardware: AudioHardware{}, Software: AudioSoftware{}, } if out, err := safeRun(ctx, "lspci"); err == nil { for _, line := range strings.Split(out, "\n") { if strings.Contains(line, "Audio device") { info.Hardware.PCIAudioDevices = append(info.Hardware.PCIAudioDevices, strings.TrimSpace(line)) } } } if out, err := safeRun(ctx, "aplay", "-l"); err == nil { info.Hardware.AlsaPlayback = out } if out, err := safeRun(ctx, "arecord", "-l"); err == nil { info.Hardware.AlsaCapture = out } if out, err := safeRun(ctx, "aplay", "-L"); err == nil { for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line != "" { info.Hardware.PCMDevices = append(info.Hardware.PCMDevices, line) } } } if out, err := safeRun(ctx, "pactl", "info"); err == nil { for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "Server Name:"): info.Software.ServerName = strings.TrimSpace(strings.TrimPrefix(line, "Server Name:")) case strings.HasPrefix(line, "Server Version:"): info.Software.ServerVersion = strings.TrimSpace(strings.TrimPrefix(line, "Server Version:")) case strings.HasPrefix(line, "Default Sink:"): value := strings.TrimSpace(strings.TrimPrefix(line, "Default Sink:")) info.Software.DefaultSink = &value case strings.HasPrefix(line, "Default Source:"): value := strings.TrimSpace(strings.TrimPrefix(line, "Default Source:")) info.Software.DefaultSource = &value } } if strings.Contains(out, "PipeWire") { info.Software.Backend = "pipewire" } else if strings.Contains(out, "PulseAudio") { info.Software.Backend = "pulseaudio" } else { info.Software.Backend = "alsa" } } return info } func collectRawInfo(ctx context.Context, include []string, maxKB int) map[string]string { raw := make(map[string]string) if maxKB <= 0 { maxKB = 5120 } for _, item := range include { item = strings.ToLower(item) var cmd []string switch item { case "lscpu": cmd = []string{"lscpu"} case "lsblk": cmd = []string{"lsblk", "-a"} case "dmidecode": cmd = []string{"dmidecode", "-t", "0,1,2,16,17"} default: continue } if len(cmd) == 0 { continue } out, err := safeRun(ctx, cmd[0], cmd[1:]...) if err != nil { continue } raw[item] = truncateOutput(out, maxKB) } return raw } func truncateOutput(s string, maxKB int) string { if len(s) <= maxKB*1024 { return s } return s[:maxKB*1024] } func atoi(value string) int { value = strings.TrimSpace(strings.Split(value, " ")[0]) if res, err := strconv.Atoi(value); err == nil { return res } return 0 } func parseMHz(value string) float64 { if f, err := strconv.ParseFloat(strings.TrimSpace(strings.Split(value, " ")[0]), 64); err == nil { return f / 1000 } return 0 } func parseSizeToKB(value string) int { value = strings.TrimSpace(value) if idx := strings.Index(value, "("); idx >= 0 { value = strings.TrimSpace(value[:idx]) } match := sizeValueRegex.FindStringSubmatch(value) if len(match) < 2 { return 0 } num, err := strconv.ParseFloat(match[1], 64) if err != nil { return 0 } unit := strings.ToLower(match[2]) multiplier := 1.0 switch unit { case "k", "kb", "kib": multiplier = 1 case "m", "mb", "mib": multiplier = 1024 case "g", "gb", "gib": multiplier = 1024 * 1024 case "t", "tb", "tib": multiplier = 1024 * 1024 * 1024 default: multiplier = 1 } return int(num * multiplier) } func printProgress(step, total int, name, status string) { // Simple affichage ANSI compatible statusColor := "\033[32m" // Vert if status == "WARN" || status == "SKIPPED" { statusColor = "\033[33m" // Jaune } else if strings.Contains(status, "ERROR") { statusColor = "\033[31m" // Rouge } reset := "\033[0m" fmt.Printf("[%d/%d] %-20s %s%s%s\n", step, total, name, statusColor, status, reset) } // ========================================== // 3. CONFIG LOADER // ========================================== func loadConfig(url string) error { var configData []byte var err error wd, wdErr := os.Getwd() localPath := "" if wdErr == nil { localPath = filepath.Join(wd, "config.yaml") if info, statErr := os.Stat(localPath); statErr == nil && !info.IsDir() { configData, err = os.ReadFile(localPath) if err == nil { fmt.Printf("INFO: Configuration locale chargée depuis %s.\n", localPath) debugf("Contenu de %s :\n%s", localPath, string(configData)) goto parse } fmt.Printf("WARN: Impossible de lire %s (%v), tentative fallback...\n", localPath, err) } else { debugf("Aucun config.yaml local (%s) : %v", localPath, statErr) } } else { debugf("Impossible de déterminer le dossier d'exécution pour chercher config.yaml : %v", wdErr) } // Tentative HTTP if url != "" { client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(url) if err == nil { defer resp.Body.Close() configData, err = io.ReadAll(resp.Body) if err == nil { fmt.Println("INFO: Configuration distante chargée.") goto parse } } fmt.Printf("WARN: Impossible de charger la config distante (%s), tentative fallback...\n", err) } // Fallback local (ou simulation pour cet exemple) // Dans le réel, on lirait /var/cache/bench-client/config.yaml fmt.Println("INFO: Utilisation de la configuration embarquée (fallback).") configData = []byte(defaultConfigYAML) // Utilisation d'une constante pour la démo parse: err = yaml.Unmarshal(configData, &cfg) if err == nil { debugf("Config chargée : %+v", cfg) } return err } // ========================================== // 4. COLLECTORS // ========================================== // ========================================== // 5. BENCHMARKS // ========================================== func runCPUBench(ctx context.Context) CPUResult { res := CPUResult{} if !cfg.Benchmarks.CPU.Enabled { return res } singleEvents, singleDuration := runSysbenchCPU(ctx, "1") multiThreads := resolveThreadCount(ctx) multiEvents, multiDuration := runSysbenchCPU(ctx, multiThreads) res.EventsPerSecSingle = round2(singleEvents) res.EventsPerSecMulti = round2(multiEvents) res.EventsPerSec = round2(averagePositive(singleEvents, multiEvents)) res.DurationSec = round2(singleDuration + multiDuration) res.ScoreSingle = res.EventsPerSecSingle res.ScoreMulti = res.EventsPerSecMulti res.Score = res.EventsPerSec return res } func runSysbenchCPU(ctx context.Context, threads string) (float64, float64) { args := []string{"cpu", fmt.Sprintf("--threads=%s", threads), "--time=10", "run"} out, err := safeRun(ctx, "sysbench", args...) if err != nil { return 0, 0 } return parseSysbenchCPU(out) } func parseSysbenchCPU(output string) (float64, float64) { var events, duration float64 if matches := sysbenchEventsRegex.FindStringSubmatch(output); len(matches) > 1 { events, _ = strconv.ParseFloat(matches[1], 64) } if matches := sysbenchTimeRegex.FindStringSubmatch(output); len(matches) > 1 { duration, _ = strconv.ParseFloat(matches[1], 64) } return events, duration } func resolveThreadCount(ctx context.Context) string { if v := os.Getenv("GOMAXPROCS"); v != "" { return v } if out, err := safeRun(ctx, "nproc"); err == nil { return strings.TrimSpace(out) } return "4" } func averagePositive(values ...float64) float64 { var sum float64 var count int for _, v := range values { if v > 0 { sum += v count++ } } if count == 0 { return 0 } return sum / float64(count) } func maxFloat(a, b float64) float64 { if b > a { return b } return a } func runMemBench(ctx context.Context) MemResult { res := MemResult{} if !cfg.Benchmarks.Memory.Enabled { return res } args := []string{"memory", "--memory-block-size=1K", "--memory-total-size=1G", "run"} if out, err := safeRun(ctx, "sysbench", args...); err == nil { if matches := regexp.MustCompile(`([\d\.]+)\s+MiB/sec`).FindStringSubmatch(out); len(matches) > 1 { if val, err := strconv.ParseFloat(matches[1], 64); err == nil { res.Throughput = round2(val) res.Score = res.Throughput } } else if matches := regexp.MustCompile(`transferred:\s+([\d\.]+)\s+MiB`).FindStringSubmatch(out); len(matches) > 1 { if val, err := strconv.ParseFloat(matches[1], 64); err == nil { res.Throughput = round2(val) res.Score = res.Throughput } } } return res } func runDiskBench(ctx context.Context) DiskResult { res := DiskResult{} if !cfg.Benchmarks.Disk.Enabled { return res } benchDir, mountInfo := chooseBenchDir() debugf("[3/6] Bench disque cible : %s (mount=%s fstype=%s source=%s)", benchDir, mountInfo.MountPoint, mountInfo.FSName, mountInfo.Source) directMetrics := runDirectDiskTests(ctx, benchDir, mountInfo) res.ReadMBs = directMetrics.ReadMBs res.WriteMBs = directMetrics.WriteMBs res.Score = averagePositive(res.ReadMBs, res.WriteMBs) tmpFile := filepath.Join(benchDir, fmt.Sprintf("bench-%d.fio", time.Now().UnixNano())) defer os.Remove(tmpFile) args := []string{ "--name=bench", "--ioengine=libaio", "--rw=randrw", "--bs=4k", "--direct=1", "--size=500M", "--numjobs=1", "--runtime=30", "--time_based", "--output-format=json", "--filename=" + tmpFile, } out, err := safeRun(ctx, "fio", args...) if err != nil { return res } var fioJSON map[string]interface{} if err := json.Unmarshal([]byte(out), &fioJSON); err != nil { return res } if jobs, ok := fioJSON["jobs"].([]interface{}); ok && len(jobs) > 0 { job := jobs[0].(map[string]interface{}) if read, ok := job["read"].(map[string]interface{}); ok { res.ReadMBs = maxFloat(res.ReadMBs, convertBW(read["bw"])) res.IOPSRead = convertFloat(read["iops"]) if hist, ok := read["clat_ns"].(map[string]interface{}); ok { res.LatencyMs = convertFloat(hist["mean"]) / 1000000 } } if write, ok := job["write"].(map[string]interface{}); ok { res.WriteMBs = maxFloat(res.WriteMBs, convertBW(write["bw"])) res.IOPSWrite = convertFloat(write["iops"]) } } res.Score = round2(averagePositive(res.ReadMBs, res.WriteMBs)) res.ReadMBs = round2(res.ReadMBs) res.WriteMBs = round2(res.WriteMBs) res.LatencyMs = round2(res.LatencyMs) res.IOPSRead = round2(res.IOPSRead) res.IOPSWrite = round2(res.IOPSWrite) debugf("[3/6] Bench disque final -> read=%.2f MB/s write=%.2f MB/s score=%.2f", res.ReadMBs, res.WriteMBs, res.Score) return res } func convertBW(value interface{}) float64 { if v, ok := value.(float64); ok { return v / 1024 } if num, ok := value.(json.Number); ok { if parsed, err := num.Float64(); err == nil { return parsed / 1024 } } return 0 } func maxInt(a, b int) int { if b > a { return b } return a } func runDirectDiskTests(ctx context.Context, benchDir string, mountInfo mountEntry) DirectDiskMetrics { if benchDir == "" { return DirectDiskMetrics{} } debugf("[3/6] Bench disque direct sur %s (mnt=%s fs=%s)", mountInfo.Source, mountInfo.MountPoint, mountInfo.FSName) tests := []struct { suffix string sizeMB int64 }{ {"500mb", 500}, {"1gb", 1024}, } var cleanup []string var totalWriteMB, totalReadMB float64 var totalWriteDur, totalReadDur time.Duration for _, test := range tests { path := filepath.Join(benchDir, fmt.Sprintf("direct-%s.bin", test.suffix)) cleanup = append(cleanup, path) debugf("[3/6] Bench direct : écriture+lecture %s (%d MB)", path, test.sizeMB) writeDur, readDur, err := benchmarkFile(ctx, path, test.sizeMB) if err != nil { debugf("[3/6] Bench direct %s échoué: %v", path, err) continue } totalWriteMB += float64(test.sizeMB) totalReadMB += float64(test.sizeMB) totalWriteDur += writeDur totalReadDur += readDur } const chunkCount = 50 const chunkSizeMB = 1 for idx := 1; idx <= chunkCount; idx++ { path := filepath.Join(benchDir, fmt.Sprintf("direct-chunk-%02d.bin", idx)) cleanup = append(cleanup, path) debugf("[3/6] Bench direct chunk %d/%d (%s)", idx, chunkCount, path) writeDur, readDur, err := benchmarkFile(ctx, path, chunkSizeMB) if err != nil { debugf("[3/6] Bench direct chunk %s échoué: %v", path, err) continue } totalWriteMB += float64(chunkSizeMB) totalReadMB += float64(chunkSizeMB) totalWriteDur += writeDur totalReadDur += readDur } cleanupPaths(cleanup) metrics := DirectDiskMetrics{ WriteMBs: round2(safeThroughput(totalWriteMB, totalWriteDur)), ReadMBs: round2(safeThroughput(totalReadMB, totalReadDur)), } debugf("[3/6] Bench disque direct résumé -> write=%.2f MB/s read=%.2f MB/s", metrics.WriteMBs, metrics.ReadMBs) return metrics } func cleanupPaths(paths []string) { for _, path := range paths { if path == "" { continue } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { debugf("Impossible de supprimer %s: %v", path, err) } } } func benchmarkFile(ctx context.Context, path string, sizeMB int64) (time.Duration, time.Duration, error) { if sizeMB <= 0 { return 0, 0, fmt.Errorf("taille invalide %d", sizeMB) } totalBytes := int64(sizeMB) * 1024 * 1024 if err := os.Remove(path); err != nil && !os.IsNotExist(err) { // ignore } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return 0, 0, err } file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { return 0, 0, err } writeBuf := make([]byte, 64*1024) fillBuffer(writeBuf) var written int64 start := time.Now() for written < totalBytes { if err := ctx.Err(); err != nil { return 0, 0, err } toWrite := int64(len(writeBuf)) if totalBytes-written < toWrite { toWrite = totalBytes - written } if _, err := file.Write(writeBuf[:toWrite]); err != nil { return 0, 0, err } written += toWrite } if err := file.Sync(); err != nil { return 0, 0, err } writeDur := time.Since(start) if err := file.Close(); err != nil { return writeDur, 0, err } readFile, err := os.Open(path) if err != nil { return writeDur, 0, err } defer readFile.Close() readBuf := make([]byte, 64*1024) start = time.Now() for { if err := ctx.Err(); err != nil { return writeDur, 0, err } if _, err := readFile.Read(readBuf); err != nil { if errors.Is(err, io.EOF) { break } return writeDur, 0, err } } readDur := time.Since(start) return writeDur, readDur, nil } func safeThroughput(mb float64, duration time.Duration) float64 { if duration <= 0 || mb <= 0 { return 0 } return mb / duration.Seconds() } func fillBuffer(buf []byte) { for i := range buf { buf[i] = byte(i % 256) } } func round2(v float64) float64 { if math.IsNaN(v) || math.IsInf(v, 0) { return v } return math.Round(v*100) / 100 } func convertFloat(value interface{}) float64 { if v, ok := value.(float64); ok { return v } return 0 } func runNetBench(ctx context.Context) NetResult { res := NetResult{} if !cfg.Benchmarks.Network.Enabled { return res } duration := 10 if params := cfg.Benchmarks.Network.Params; params != nil { if raw, ok := params["duration_s"]; ok { switch v := raw.(type) { case int: duration = v case float64: duration = int(v) case string: if parsed, err := strconv.Atoi(v); err == nil { duration = parsed } } } } args := []string{ "-c", cfg.Benchmarks.Network.Server, "-p", strconv.Itoa(cfg.Benchmarks.Network.Port), "-J", "-t", strconv.Itoa(duration), } out, err := safeRun(ctx, "iperf3", args...) if err != nil { return res } var iperfJSON map[string]interface{} if err := json.Unmarshal([]byte(out), &iperfJSON); err != nil { return res } if end, ok := iperfJSON["end"].(map[string]interface{}); ok { if sumSent, ok := end["sum_sent"].(map[string]interface{}); ok { if val := convertFloat(sumSent["bits_per_second"]); val > 0 { res.Upload = round2(val / 1000 / 1000) } if val := convertFloat(sumSent["jitter_ms"]); val > 0 { res.JitterMs = floatPtr(round2(val)) } if val := convertFloat(sumSent["lost_percent"]); val > 0 { res.PacketLossPct = floatPtr(round2(val)) } } if sumRecv, ok := end["sum_received"].(map[string]interface{}); ok { if val := convertFloat(sumRecv["bits_per_second"]); val > 0 { res.Download = round2(val / 1000 / 1000) } } } res.Score = round2(averagePositive(res.Upload, res.Download)) // Ping test if cfg.Benchmarks.Network.Server != "" { if ping := measurePing(ctx, cfg.Benchmarks.Network.Server); ping > 0 { res.PingMs = round2(ping) } } return res } func measurePing(ctx context.Context, target string) float64 { out, err := safeRun(ctx, "ping", "-c", "1", "-W", "1", target) if err != nil { return 0 } re := regexp.MustCompile(`time=([\d\.]+)\s*ms`) if matches := re.FindStringSubmatch(out); len(matches) > 1 { if val, err := strconv.ParseFloat(matches[1], 64); err == nil { return val } } return 0 } // ========================================== // 6. MAIN ORCHESTRATOR // ========================================== const defaultConfigYAML = ` config_version: 1 runtime: max_total_runtime_s: 480 command_timeout_s: 30 temp_dir: "/tmp" backend: url: "http://10.0.0.50:8007/api/benchmark" logging: level: info progress: true show_command_lines: false capture_raw_output: true raw_output_max_kb: 5120 save_payload_to_tmp: true payload_tmp_dir: "/tmp" payload_filename_prefix: "bench_payload_" interactive_pause_on_debug: true payload: dir: "." prefix: "bench_payload_" always_save: true collection: system: { enabled: true } cpu: { enabled: true } ram: { enabled: true } storage: { enabled: true } network: { enabled: true } raw_info: enabled: true include: [lscpu, lsblk, dmidecode] max_size_kb: 5120 benchmarks: enabled: true weights: { cpu: 0.4, memory: 0.2, disk: 0.2, network: 0.1, gpu: 0.1 } cpu_sysbench: enabled: true params: { cpu_max_prime: 20000 } memory_sysbench: enabled: true params: { total_size: "1G" } disk_fio: enabled: true safety: { max_runtime_s: 60 } network_iperf3: enabled: true server: "127.0.0.1" # Default pour test port: 5201 params: { duration_s: 10 } ` func calculateScore(h Hardware, r Results) float64 { // Normalisation arbitraire pour l'exemple // CPU: assume 2000 events/sec is 100 pts cpuScore := ((r.CPU.ScoreSingle + r.CPU.ScoreMulti) / 2) / 20 memScore := r.Memory.Throughput / 100 // assume 1000 MB/s is 100 pts diskScore := r.Disk.Score / 10 // assume 500 MB/s is 50 pts netScore := r.Network.Score / 10 // assume 1000 Mbps is 100 pts gpuScore := 0.0 if r.GPU.Score != nil { gpuScore = *r.GPU.Score } w := cfg.Benchmarks.Weights debugf("Poids benchmarks : %+v", w) debugf("Scores normalisés : cpu=%.2f mem=%.2f disk=%.2f net=%.2f", cpuScore, memScore, diskScore, netScore) return (cpuScore*w["cpu"] + memScore*w["memory"] + diskScore*w["disk"] + netScore*w["network"] + gpuScore*w["gpu"]) * 100 } func persistPayload(data []byte) string { cacheDir := cfg.Payload.Dir if cacheDir == "" { if wd, err := os.Getwd(); err == nil { cacheDir = wd } else { fmt.Printf("WARN: impossible de déterminer le dossier d'exécution : %v\n", err) cacheDir = os.TempDir() } } if err := os.MkdirAll(cacheDir, 0o755); err != nil { fmt.Printf("WARN: impossible de créer %s : %v\n", cacheDir, err) return "" } prefix := cfg.Payload.Prefix if prefix == "" { prefix = cfg.Logging.PayloadFilenamePrefix } if prefix == "" { prefix = "bench_payload_" } filename := prefix + "last.json" path := filepath.Join(cacheDir, filename) if err := os.WriteFile(path, data, 0o777); err != nil { fmt.Printf("WARN: impossible d'écrire %s : %v\n", path, err) return "" } debugf("Payload sauvegardé au format JSON dans %s (%d octets)", path, len(data)) return path } func main() { // Flags configURL := flag.String("config-url", "", "URL de la config distante") flag.BoolVar(&debug, "debug", false, "Mode debug") flag.BoolVar(&dryRun, "dry-run", false, "Simulation d'exécution") flag.Parse() defer closeDebugLog() // 1. Chargement Config fmt.Println("[1/6] Configuration...") if err := loadConfig(*configURL); err != nil { fmt.Printf("FATAL: Erreur chargement config: %v\n", err) os.Exit(1) } printProgress(1, 6, "Configuration", "OK") debugf("Configuration complète : %+v", cfg) // 2. Contexte global ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Runtime.MaxTotalRuntime)*time.Second) defer cancel() // 3. Collecte Info fmt.Println("[2/6] Collecte informations système...") debugf("[2/6] Début collecte CPU") cpuDetails := collectCPUInfo(ctx) debugf("[2/6] Détails CPU: %+v", cpuDetails) debugf("[2/6] Début collecte RAM") ramDetails := collectRAMInfo(ctx) debugf("[2/6] Détails RAM: %+v", ramDetails) debugf("[2/6] Début collecte GPU") gpuDetails := collectGPUInfo(ctx) debugf("[2/6] Détails GPU: %+v", gpuDetails) debugf("[2/6] Début collecte stockage") storageDetails := collectStorageInfo(ctx) debugf("[2/6] Détails stockage: %+v", storageDetails) debugf("[2/6] Début collecte PCI devices") pciDetails := collectPCIDevices(ctx) debugf("[2/6] Détails PCI: %+v", pciDetails) debugf("[2/6] Début collecte USB devices") usbDetails := collectUSBDevices(ctx) debugf("[2/6] Détails USB: %+v", usbDetails) debugf("[2/6] Début collecte partages réseau") networkShares := collectNetworkShares(ctx) debugf("[2/6] Détails partages réseau: %+v", networkShares) debugf("[2/6] Début collecte interfaces réseau") networkDetails := collectNetworkInfo(ctx) debugf("[2/6] Détails réseau: %+v", networkDetails) debugf("[2/6] Début collecte carte mère") motherboardInfo := collectMotherboardInfo(ctx) debugf("[2/6] Détails carte mère: %+v", motherboardInfo) debugf("[2/6] Début collecte OS") osInfo := collectOSInfo(ctx) debugf("[2/6] Détails OS: %+v", osInfo) debugf("[2/6] Début collecte capteurs") sensorInfo := collectSensors(ctx) debugf("[2/6] Détails capteurs: %+v", sensorInfo) debugf("[2/6] Début collecte audio") audioInfo := collectAudioInfo(ctx) debugf("[2/6] Détails audio: %+v", audioInfo) hw := Hardware{ CPU: cpuDetails, RAM: ramDetails, GPU: gpuDetails, Storage: storageDetails, PCIDevices: pciDetails, USBDevices: usbDetails, NetworkShares: networkShares, Network: networkDetails, Motherboard: motherboardInfo, OS: osInfo, Sensors: sensorInfo, Audio: audioInfo, } if cfg.Logging.CaptureRawOutput { hw.RawInfo = collectRawInfo(ctx, cfg.RawInfo.Include, cfg.RawInfo.MaxSizeKB) } printProgress(2, 6, "Collecte", "OK") debugf("Matériel collecté : %+v", hw) // 4. Benchmarks fmt.Println("[3/6] Exécution Benchmarks...") debugf("[3/6] Benchmark CPU démarré") cpuResult := runCPUBench(ctx) debugf("[3/6] Résultat CPU: %+v", cpuResult) debugf("[3/6] Benchmark mémoire démarré") memResult := runMemBench(ctx) debugf("[3/6] Résultat mémoire: %+v", memResult) debugf("[3/6] Benchmark disque démarré") diskResult := runDiskBench(ctx) debugf("[3/6] Résultat disque: %+v", diskResult) debugf("[3/6] Benchmark réseau démarré") netResult := runNetBench(ctx) debugf("[3/6] Résultat réseau: %+v", netResult) results := Results{ CPU: cpuResult, Memory: memResult, Disk: diskResult, Network: netResult, } printProgress(3, 6, "Benchmarks", "OK") debugf("Résultats des benchmarks : %+v", results) // 5. Score fmt.Println("[4/6] Calcul Score...") results.GlobalScore = calculateScore(hw, results) printProgress(4, 6, "Score", fmt.Sprintf("%.2f", results.GlobalScore)) debugf("Score global calculé : %.2f", results.GlobalScore) // 6. Payload & Envoi rawData := hw.RawInfo if rawData == nil { rawData = make(map[string]string) } if debug { rawData["debug"] = "active" } payload := FinalPayload{ DeviceIdentifier: hw.OS.Hostname, BenchScriptVersion: benchScriptVersion, BenchClientVersion: benchClientVersion, Hardware: hw, Results: results, RawInfo: rawData, } jsonData, err := json.MarshalIndent(payload, "", " ") if err != nil { fmt.Printf("FATAL: JSON error: %v\n", err) os.Exit(1) } printProgress(5, 6, "JSON", "OK") debugf("Payload JSON (%d octets) : %s", len(jsonData), string(jsonData)) fmt.Println("[6/6] Envoi Backend...") if !dryRun { token, tokenSet := os.LookupEnv("API_TOKEN") if !tokenSet { token = "test_hardware_perf" fmt.Println("INFO: API_TOKEN absent, envoi avec token de test 'test_hardware_perf'.") } else { debugf("API_TOKEN fourni via l'environnement.") } req, _ := http.NewRequest("POST", cfg.Backend.URL, bytes.NewBuffer(jsonData)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { printProgress(6, 6, "Envoi HTTP", "ERROR") fmt.Printf("Erreur: %v\n", err) } else { defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { printProgress(6, 6, "Envoi HTTP", "OK") } else { printProgress(6, 6, "Envoi HTTP", fmt.Sprintf("FAIL %d", resp.StatusCode)) } } } else { fmt.Println("DRY RUN: Payload non envoyé.") fmt.Println(string(jsonData)) } if path := persistPayload(jsonData); path != "" { fmt.Printf("INFO: Payload enregistré dans %s\n", path) } }