@@ -58,37 +58,26 @@ func RunAgent(ctx context.Context, ready chan<- struct{}) error {
5858 }
5959
6060 // --discover flag: probe drives, detect protocols, optionally write config.
61- // Must exit before preflight checks (no HTTP server started ).
61+ // Must resolve smartctl path before running (discover needs to call smartctl ).
6262 if cfg .Discover {
63+ smartctlPath , _ , resolveErr := resolveSmartctlPath ("7.0" )
64+ if resolveErr != nil {
65+ return resolveErr
66+ }
67+ cfg .SmartctlPath = smartctlPath
6368 return RunDiscover (cfg , cfg .NoWrite )
6469 }
6570
66- // --- Preflight checks (order matters) ---
67- if err := preflightSmartctlExists (); err != nil {
68- return err
69- }
70-
71- smartctlVer , err := preflightSmartctlVersion ()
71+ // --- Preflight: resolve smartctl binary ---
72+ const minSmartctlVersion = "7.0"
73+ smartctlPath , smartctlVer , err := resolveSmartctlPath (minSmartctlVersion )
7274 if err != nil {
7375 return err
7476 }
77+ cfg .SmartctlPath = smartctlPath
78+ log .Printf ("using smartctl: %s (version %s)" , smartctlPath , smartctlVer )
7579
76- const minSmartctlVersion = "7.0"
77- if ! isSmartctlVersionOK (smartctlVer , minSmartctlVersion ) {
78- return fmt .Errorf (`ERROR: smartctl %s is too old. The agent requires smartctl %s or newer
79- for JSON output support (--json flag).
80-
81- Your options:
82- 1. Update smartmontools from https://www.smartmontools.org/wiki/Download
83- 2. Check if a newer version is available in your package manager:
84- apt: sudo apt install smartmontools
85- dnf: sudo dnf install smartmontools
86- brew: brew install smartmontools
87-
88- Run 'smartctl --version' to check your current version.` , smartctlVer , minSmartctlVersion )
89- }
90-
91- drives , err := preflightScanDrives ()
80+ drives , err := preflightScanDrives (cfg .SmartctlPath )
9281 if err != nil {
9382 return err
9483 }
@@ -302,38 +291,9 @@ func detectOS() string {
302291// Preflight checks
303292// ---------------------------------------------------------------------------
304293
305- func preflightSmartctlExists () error {
306- _ , err := exec .LookPath ("smartctl" )
307- if err != nil {
308- return fmt .Errorf (`ERROR: smartctl not found in PATH.
309- smartmontools is required for SMART Sniffer to function.
310-
311- Install it for your platform:
312- Linux (Debian/Ubuntu): sudo apt install smartmontools
313- Linux (RHEL/Fedora): sudo dnf install smartmontools
314- macOS (Homebrew): brew install smartmontools
315- Windows (Chocolatey): choco install smartmontools
316-
317- More info: https://www.smartmontools.org/wiki/Download
318- ` )
319- }
320- return nil
321- }
322-
323- // preflightSmartctlVersion runs "smartctl --version", extracts the version
324- // string and returns it. Exits on unexpected failure.
325- func preflightSmartctlVersion () (string , error ) {
326- out , err := exec .Command ("smartctl" , "--version" ).CombinedOutput ()
327- if err != nil {
328- return "" , fmt .Errorf ("ERROR: failed to run smartctl --version: %v\n Output: %s" , err , string (out ))
329- }
330- ver := parseSmartctlVersion (string (out ))
331- if ver == "" {
332- ver = "unknown"
333- }
334- log .Printf ("smartctl %s detected" , ver )
335- return ver , nil
336- }
294+ // preflightSmartctlExists and preflightSmartctlVersion have been replaced
295+ // by resolveSmartctlPath() which handles both existence and version checking
296+ // with fallback to known platform-specific paths.
337297
338298// parseSmartctlVersion extracts a version like "7.4" from the --version output.
339299var versionRe = regexp .MustCompile (`smartctl\s+(\d+\.\d+)` )
@@ -367,10 +327,112 @@ func isSmartctlVersionOK(ver, minVer string) bool {
367327 return vMaj > mMaj || (vMaj == mMaj && vMin >= mMin )
368328}
369329
330+ // smartctlSearchPaths lists known installation locations for smartctl across
331+ // platforms. Checked in order when the PATH version is missing or too old.
332+ // See docs/internal/research/smartctl-install-paths.md for full research.
333+ var smartctlSearchPaths = []string {
334+ // NAS platforms (most likely to need fallback)
335+ "/var/packages/synocli-disk/target/sbin/smartctl" , // SynoCommunity on Synology
336+ "/opt/sbin/smartctl" , // Entware (Synology/QNAP)
337+ "/opt/bin/smartctl" , // Entware alternate
338+ "/boot/extra/sbin/smartctl" , // Unraid NerdTools
339+ "/boot/extra/bin/smartctl" , // Unraid NerdTools alternate
340+ "/share/CACHEDEV1_DATA/.qpkg/smartmontools/bin/smartctl" , // QNAP QPKG
341+
342+ // Standard Linux
343+ "/usr/sbin/smartctl" , // Debian, Ubuntu, RHEL, Fedora, Arch, Alpine, Proxmox, OMV
344+
345+ // BSD / TrueNAS CORE
346+ "/usr/local/sbin/smartctl" , // FreeBSD, OpenBSD
347+
348+ // macOS
349+ "/usr/local/bin/smartctl" , // Homebrew (Intel)
350+ "/opt/homebrew/bin/smartctl" , // Homebrew (Apple Silicon)
351+ "/opt/local/sbin/smartctl" , // MacPorts
352+
353+ // NixOS
354+ "/run/current-system/sw/sbin/smartctl" , // NixOS system profile
355+ }
356+
357+ // resolveSmartctlPath finds the best smartctl binary available. It checks
358+ // PATH first, then falls back to known platform-specific paths. Returns the
359+ // full path and version string, or an error if no usable binary is found.
360+ func resolveSmartctlPath (minVersion string ) (string , string , error ) {
361+ // 1. Try PATH first (current behavior for most users).
362+ pathBin , err := exec .LookPath ("smartctl" )
363+ if err == nil {
364+ ver := getSmartctlVersion (pathBin )
365+ if isSmartctlVersionOK (ver , minVersion ) {
366+ return pathBin , ver , nil
367+ }
368+ log .Printf ("smartctl in PATH is %s (requires %s+), searching known paths..." , ver , minVersion )
369+ }
370+
371+ // 2. Search known platform-specific paths.
372+ for _ , candidate := range smartctlSearchPaths {
373+ info , statErr := os .Stat (candidate )
374+ if statErr != nil || info .IsDir () {
375+ continue
376+ }
377+ if info .Mode ()& 0111 == 0 {
378+ continue // not executable
379+ }
380+ ver := getSmartctlVersion (candidate )
381+ if ver == "" {
382+ continue
383+ }
384+ if isSmartctlVersionOK (ver , minVersion ) {
385+ log .Printf ("found smartctl %s at %s" , ver , candidate )
386+ return candidate , ver , nil
387+ }
388+ }
389+
390+ // 3. Nothing found.
391+ if pathBin != "" {
392+ return "" , "" , fmt .Errorf (`ERROR: smartctl found in PATH but version is too old.
393+ The agent requires smartctl %s or newer for JSON output support.
394+
395+ The smartctl in your PATH is at: %s
396+
397+ Install a newer version:
398+ Linux (Debian/Ubuntu): sudo apt install smartmontools
399+ Linux (RHEL/Fedora): sudo dnf install smartmontools
400+ macOS (Homebrew): brew install smartmontools
401+ Synology: Install SynoCli Disk Tools from SynoCommunity
402+ QNAP: Install smartmontools via Entware (opkg install smartmontools)
403+
404+ Run 'smartctl --version' to check your current version.` , minVersion , pathBin )
405+ }
406+
407+ return "" , "" , fmt .Errorf (`ERROR: smartctl not found in PATH or known locations.
408+ smartmontools is required for SMART Sniffer to function.
409+
410+ Install it for your platform:
411+ Linux (Debian/Ubuntu): sudo apt install smartmontools
412+ Linux (RHEL/Fedora): sudo dnf install smartmontools
413+ macOS (Homebrew): brew install smartmontools
414+ Windows (Chocolatey): choco install smartmontools
415+ Synology: Install SynoCli Disk Tools from SynoCommunity
416+ QNAP: Install smartmontools via Entware (opkg install smartmontools)
417+
418+ More info: https://www.smartmontools.org/wiki/Download
419+ ` )
420+ }
421+
422+ // getSmartctlVersion runs "<path> --version" and returns the version string,
423+ // or "" if it can't be determined.
424+ func getSmartctlVersion (path string ) string {
425+ out , err := exec .Command (path , "--version" ).CombinedOutput ()
426+ if err != nil {
427+ return ""
428+ }
429+ return parseSmartctlVersion (string (out ))
430+ }
431+
370432// preflightScanDrives runs "smartctl --scan" and checks for permission errors
371433// or zero drives.
372- func preflightScanDrives () ([]string , error ) {
373- out , err := exec .Command ("smartctl" , "--scan" ).CombinedOutput ()
434+ func preflightScanDrives (smartctlPath string ) ([]string , error ) {
435+ out , err := exec .Command (smartctlPath , "--scan" ).CombinedOutput ()
374436 outStr := string (out )
375437
376438 // Permission errors surface in different ways depending on OS.
@@ -558,10 +620,10 @@ func (dc *DriveCache) Refresh() {
558620 }
559621 dc .mu .Unlock ()
560622
561- scanOut , err := exec .Command ("smartctl" , "--json" , scanCmd ).CombinedOutput ()
623+ scanOut , err := exec .Command (dc . cfg . SmartctlPath , "--json" , scanCmd ).CombinedOutput ()
562624 if err != nil && scanCmd == "--scan-open" {
563625 log .Println ("--scan-open failed, falling back to --scan" )
564- scanOut , err = exec .Command ("smartctl" , "--json" , "--scan" ).CombinedOutput ()
626+ scanOut , err = exec .Command (dc . cfg . SmartctlPath , "--json" , "--scan" ).CombinedOutput ()
565627 }
566628 if err != nil {
567629 log .Printf ("drive scan error: %v" , err )
@@ -703,8 +765,8 @@ func decodeHealthBits(code int) string {
703765
704766// runSmartctl runs smartctl with the given args and returns (output, exitCode, error).
705767// error is non-nil only for non-ExitError failures (missing binary, permissions, etc.).
706- func runSmartctl (args []string ) ([]byte , int , error ) {
707- out , err := exec .Command ("smartctl" , args ... ).CombinedOutput ()
768+ func runSmartctl (smartctlPath string , args []string ) ([]byte , int , error ) {
769+ out , err := exec .Command (smartctlPath , args ... ).CombinedOutput ()
708770 if err != nil {
709771 if exitErr , ok := err .(* exec.ExitError ); ok {
710772 return out , exitErr .ExitCode (), nil
@@ -736,7 +798,7 @@ func (dc *DriveCache) fetchDriveInfo(devicePath, protocol string) (DriveInfo, bo
736798 }
737799 args = append (args , devicePath )
738800
739- out , code , execErr := runSmartctl (args )
801+ out , code , execErr := runSmartctl (dc . cfg . SmartctlPath , args )
740802 if execErr != nil {
741803 // Non-ExitError (binary missing, permissions, etc.)
742804 log .Printf ("WARNING: smartctl -a %s: %v" , devicePath , execErr )
@@ -755,7 +817,7 @@ func (dc *DriveCache) fetchDriveInfo(devicePath, protocol string) (DriveInfo, bo
755817 }
756818 satArgs = append (satArgs , devicePath )
757819
758- satOut , satCode , satExecErr := runSmartctl (satArgs )
820+ satOut , satCode , satExecErr := runSmartctl (dc . cfg . SmartctlPath , satArgs )
759821 if satExecErr == nil && satCode & 0x02 == 0 {
760822 log .Printf ("INFO: %s reports as SCSI but SAT succeeded -- using SAT for this drive" , devicePath )
761823 dc .mu .Lock ()
0 commit comments