Skip to content

Commit 41ddeb0

Browse files
committed
v0.5.5.1: auto-resolve smartctl path across NAS platforms
When the smartctl in PATH is missing or older than 7.0, the agent now searches 13 known platform-specific install locations before failing. Fixes Synology DSM where system smartctl 6.5 shadows SynoCommunity 7.4+. Zero new flags, zero new config -- the agent finds the right binary itself. Ref: #17 (EagleDTW, Synology DS925+)
1 parent fef9d9f commit 41ddeb0

4 files changed

Lines changed: 142 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to SMART Sniffer are documented here.
44

5+
## v0.5.5.1 -- 2026-04-24
6+
7+
Agent-only patch. No integration, installer, or config changes.
8+
9+
Addresses the PATH resolution issue reported by @EagleDTW in [#17](https://github.com/DAB-LABS/smart-sniffer/issues/17) -- Synology DSM (and other NAS platforms) ship an outdated smartctl in `/usr/bin` that shadows newer versions installed via package managers.
10+
11+
### Fixed
12+
- **smartctl path auto-resolution** -- the agent no longer relies solely on `PATH` to find `smartctl`. If the version in `PATH` is missing or older than 7.0, the agent searches 13 known platform-specific install locations (SynoCommunity, Entware, Homebrew, MacPorts, NixOS, Unraid NerdTools, QNAP QPKG, standard Linux/BSD paths). The first 7.0+ binary found is used automatically. Zero new flags, zero config -- the agent finds the right binary itself. Logged on startup: `using smartctl: /path/to/smartctl (version X.Y)`.
13+
514
## v0.5.5 -- 2026-04-24
615

716
Agent-only release. No integration or installer changes required.

agent/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type Config struct {
4141
DeviceOverrides []DeviceOverride `yaml:"device_overrides"` // manual protocol overrides per device path
4242
Discover bool `yaml:"-"` // set by --discover flag; not read from config file
4343
NoWrite bool `yaml:"-"` // set by --no-write flag; skips config write in discover mode
44+
SmartctlPath string `yaml:"-"` // resolved path to smartctl binary; set by resolveSmartctlPath()
4445
}
4546

4647
// defaultConfig returns sane defaults.

agent/discover.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ func RunDiscover(cfg *Config, noWrite bool) error {
3131
fmt.Println("Scanning drives...")
3232

3333
// Always use --scan-open in discover mode for best protocol detection.
34-
scanOut, err := exec.Command("smartctl", "--json", "--scan-open").CombinedOutput()
34+
scanOut, err := exec.Command(cfg.SmartctlPath, "--json", "--scan-open").CombinedOutput()
3535
if err != nil {
3636
// Fall back to --scan if --scan-open is unsupported.
37-
scanOut, err = exec.Command("smartctl", "--json", "--scan").CombinedOutput()
37+
scanOut, err = exec.Command(cfg.SmartctlPath, "--json", "--scan").CombinedOutput()
3838
if err != nil {
3939
return fmt.Errorf("smartctl --scan failed: %v", err)
4040
}
@@ -56,7 +56,7 @@ func RunDiscover(cfg *Config, noWrite bool) error {
5656
fmt.Printf(" Standard scan found 0 drives.\n")
5757
} else {
5858
for _, dev := range scanResult.Devices {
59-
r := probeOneDrive(dev.Name, dev.Protocol)
59+
r := probeOneDrive(cfg.SmartctlPath, dev.Name, dev.Protocol)
6060
results = append(results, r)
6161
printDriveResult(r)
6262
}
@@ -90,7 +90,7 @@ func RunDiscover(cfg *Config, noWrite bool) error {
9090
continue
9191
}
9292

93-
r := probeOneDrive(path, "sat")
93+
r := probeOneDrive(cfg.SmartctlPath, path, "sat")
9494
results = append(results, r)
9595
printDriveResult(r)
9696
}
@@ -176,7 +176,7 @@ func RunDiscover(cfg *Config, noWrite bool) error {
176176

177177
// probeOneDrive attempts to read SMART data from a single drive path, trying
178178
// SAT fallback if the initial protocol fails. Returns a discoverDriveResult.
179-
func probeOneDrive(path, protocol string) discoverDriveResult {
179+
func probeOneDrive(smartctlPath, path, protocol string) discoverDriveResult {
180180
r := discoverDriveResult{path: path, scanProto: protocol}
181181

182182
args := []string{"--json", "-a"}
@@ -185,7 +185,7 @@ func probeOneDrive(path, protocol string) discoverDriveResult {
185185
}
186186
args = append(args, path)
187187

188-
out, err := exec.Command("smartctl", args...).CombinedOutput()
188+
out, err := exec.Command(smartctlPath, args...).CombinedOutput()
189189
code := 0
190190
if err != nil {
191191
if exitErr, ok := err.(*exec.ExitError); ok {
@@ -210,7 +210,7 @@ func probeOneDrive(path, protocol string) discoverDriveResult {
210210
if strings.EqualFold(protocol, "scsi") || strings.EqualFold(protocol, "sat") {
211211
r.satRetried = true
212212
satArgs := []string{"--json", "-a", "-d", "sat", path}
213-
satOut, satErr := exec.Command("smartctl", satArgs...).CombinedOutput()
213+
satOut, satErr := exec.Command(smartctlPath, satArgs...).CombinedOutput()
214214
satCode := 0
215215
if satErr != nil {
216216
if satExitErr, ok := satErr.(*exec.ExitError); ok {

agent/main.go

Lines changed: 125 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nOutput: %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.
339299
var 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

Comments
 (0)