7 Commits
Author SHA1 Message Date
Gilles SoulierandClaude Sonnet 4.6 7fb47ffde8 fix(smart v0.1.17): smart_status optionnel + AmbientCapabilities CAP_SYS_ADMIN
- SmartJson.smart_status devient Option<SmartStatus> avec #[serde(default)]
  → parsing non-bloquant si le champ est absent (ex: NVME_IOCTL_ADMIN_CMD échoue)
- Service: suppression NoNewPrivileges, ajout AmbientCapabilities=CAP_SYS_ADMIN
  → smartctl hérite la capability via execve (kernel ≥ 5.2)
- Nettoyage logs debug (suppression dump JSON brut)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:10:53 +02:00
Gilles SoulierandClaude Sonnet 4.6 3c15943e2e debug(smart v0.1.16): log JSON brut complet en cas d'échec parse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:51:30 +02:00
Gilles SoulierandClaude Sonnet 4.6 a9506a5505 fix(smart v0.1.15): contrôleur NVMe + règle udev disk group
Cause racine : smartctl -a -j /dev/nvme0n1 (namespace) retourne exit 4
et omet smart_status car les commandes admin échouent via le namespace.
Solution : utiliser /dev/nvme0 (contrôleur) accessible grâce à la règle
udev SUBSYSTEM==nvme GROUP=disk.

- smart.rs : scan /sys/class/nvme/ pour les contrôleurs (nvme0, nvme1)
  au lieu de /sys/block/ pour les namespaces (nvme0n1)
- deploy/99-nanometrics-smart.rules : udev rule KERNEL==nvme* GROUP=disk
- deploy/install.sh : déploie la règle udev + udevadm trigger

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:39:13 +02:00
Gilles SoulierandClaude Sonnet 4.6 ee5e8710a3 fix(smart v0.1.14): filtre NVMe correct + SmartStatus défensif
- Filtre nvme : n[4..].contains('n') au lieu de n.contains('n')
  pour distinguer nvme0n1 (namespace) de nvme0 (contrôleur)
- SmartStatus.passed : #[serde(default)] pour éviter crash si absent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:20:04 +02:00
Gilles SoulierandClaude Sonnet 4.6 d715b452c1 fix(smart v0.1.13): SmartTemp.current optionnel — évite échec parse JSON
Certains NVMe (ASUS TUF A16) ont un champ temperature sans current.
Le champ requis current: i64 faisait crasher toute la désérialisation.
Correction : #[serde(default)] + and_then au lieu de map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:11:10 +02:00
Gilles SoulierandClaude Sonnet 4.6 fdeb4c2088 debug(smart v0.1.12): logging détaillé pour diagnostiquer smart=nil
Logs étape par étape : détection devices, exit code smartctl,
taille stdout/stderr, résultat parse JSON.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:56:51 +02:00
Gilles SoulierandClaude Sonnet 4.6 66605e22e3 fix(server): logging UDP — debug SMART + format erreur JSON
Cargo.lock mis à jour pour refléter la version 0.1.11 de l'agent.
Logging temporaire côté serveur pour tracer les payloads SMART.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:49:32 +02:00
7 changed files with 85 additions and 44 deletions
+1 -1
View File
@@ -248,7 +248,7 @@ dependencies = [
[[package]] [[package]]
name = "nanometrics-agent" name = "nanometrics-agent"
version = "0.1.10" version = "0.1.17"
dependencies = [ dependencies = [
"libc", "libc",
"rumqttc", "rumqttc",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nanometrics-agent" name = "nanometrics-agent"
version = "0.1.11" version = "0.1.17"
edition = "2021" edition = "2021"
[lib] [lib]
+52 -32
View File
@@ -2,17 +2,24 @@ use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmartJson { struct SmartJson {
smart_status: SmartStatus, #[serde(default)]
smart_status: Option<SmartStatus>,
temperature: Option<SmartTemp>, temperature: Option<SmartTemp>,
ata_smart_attributes: Option<SmartAttrs>, ata_smart_attributes: Option<SmartAttrs>,
nvme_smart_health_information_log: Option<NvmeHealth>, nvme_smart_health_information_log: Option<NvmeHealth>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmartStatus { passed: bool } struct SmartStatus {
#[serde(default)]
passed: bool,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmartTemp { current: i64 } struct SmartTemp {
#[serde(default)]
current: Option<i64>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmartAttrs { table: Vec<SmartAttr> } struct SmartAttrs { table: Vec<SmartAttr> }
@@ -44,7 +51,7 @@ pub fn is_available() -> bool {
pub fn parse_json(json: &str) -> Result<crate::payload::SmartMetrics, serde_json::Error> { pub fn parse_json(json: &str) -> Result<crate::payload::SmartMetrics, serde_json::Error> {
let s: SmartJson = serde_json::from_str(json)?; let s: SmartJson = serde_json::from_str(json)?;
let temperature = s.temperature.as_ref().map(|t| t.current) let temperature = s.temperature.as_ref().and_then(|t| t.current)
.or_else(|| s.nvme_smart_health_information_log.as_ref()?.temperature); .or_else(|| s.nvme_smart_health_information_log.as_ref()?.temperature);
let mut reallocated = None; let mut reallocated = None;
@@ -72,7 +79,7 @@ pub fn parse_json(json: &str) -> Result<crate::payload::SmartMetrics, serde_json
Ok(crate::payload::SmartMetrics { Ok(crate::payload::SmartMetrics {
device: String::new(), device: String::new(),
passed: s.smart_status.passed, passed: s.smart_status.as_ref().map(|s| s.passed).unwrap_or(false),
temperature, temperature,
reallocated_sectors: reallocated, reallocated_sectors: reallocated,
power_on_hours: power_hours, power_on_hours: power_hours,
@@ -82,41 +89,54 @@ pub fn parse_json(json: &str) -> Result<crate::payload::SmartMetrics, serde_json
pub fn collect() -> Option<Vec<crate::payload::SmartMetrics>> { pub fn collect() -> Option<Vec<crate::payload::SmartMetrics>> {
if !is_available() { if !is_available() {
eprintln!("[smart] smartctl introuvable dans PATH");
return None; return None;
} }
let mut devs: Vec<String> = std::fs::read_dir("/sys/block") let mut set = std::collections::HashSet::new();
.into_iter()
.flatten() // SATA/SAS : /sys/block/sd* → /dev/sda, /dev/sdb…
.flatten() for e in std::fs::read_dir("/sys/block").into_iter().flatten().flatten() {
.map(|e| e.file_name().into_string().unwrap_or_default()) let n = e.file_name().into_string().unwrap_or_default();
.filter_map(|n| { if n.starts_with("sd") {
if n.starts_with("sd") { set.insert(format!("/dev/{}", n));
// /dev/sda, /dev/sdb — block device, groupe disk OK }
Some(format!("/dev/{}", n)) }
} else if n.starts_with("nvme") && n.contains('n') {
// /dev/nvme0n1 — block device (brw-rw---- root disk), groupe disk OK // NVMe : /sys/class/nvme/nvme* → /dev/nvme0, /dev/nvme1…
// NE PAS utiliser /dev/nvme0 (contrôleur crw------- root root, root only) // On utilise le contrôleur (char device), pas le namespace (block device),
Some(format!("/dev/{}", n)) // car smartctl ne peut exécuter les commandes admin SMART que via le contrôleur.
} else { // La règle udev 99-nanometrics-smart.rules lui donne l'accès groupe disk.
None for e in std::fs::read_dir("/sys/class/nvme").into_iter().flatten().flatten() {
} let n = e.file_name().into_string().unwrap_or_default();
}) if n.starts_with("nvme") {
.collect::<std::collections::HashSet<_>>() set.insert(format!("/dev/{}", n));
.into_iter() }
.collect(); }
let mut devs: Vec<String> = set.into_iter().collect();
devs.sort(); devs.sort();
eprintln!("[smart] disques détectés: {:?}", devs);
let mut results = Vec::new(); let mut results = Vec::new();
for dev in &devs { for dev in &devs {
let Ok(output) = std::process::Command::new("smartctl") let output = match std::process::Command::new("smartctl")
.args(["-a", "-j", dev]) .args(["-a", "-j", dev])
.output() else { continue }; .output()
{
Ok(o) => o,
Err(e) => { eprintln!("[smart] erreur exec smartctl {}: {}", dev, e); continue }
};
let json = String::from_utf8_lossy(&output.stdout); let json = String::from_utf8_lossy(&output.stdout);
if let Ok(metrics) = parse_json(&json) { match parse_json(&json) {
results.push(crate::payload::SmartMetrics { Ok(metrics) => {
device: dev.trim_start_matches("/dev/").to_string(), results.push(crate::payload::SmartMetrics {
..metrics device: dev.trim_start_matches("/dev/").to_string(),
}); ..metrics
});
}
Err(e) => {
eprintln!("[smart] {} parse JSON échoué: {}", dev, e);
}
} }
} }
if results.is_empty() { None } else { Some(results) } if results.is_empty() { None } else { Some(results) }
+4
View File
@@ -0,0 +1,4 @@
# Nanometrics: accès groupe disk aux contrôleurs NVMe pour SMART
# Sans cette règle, /dev/nvme0 est crw------- root root (root only),
# ce qui empêche smartctl d'exécuter les commandes admin et omet smart_status du JSON.
KERNEL=="nvme[0-9]*", SUBSYSTEM=="nvme", GROUP="disk", MODE="0660"
+11
View File
@@ -45,6 +45,17 @@ else
fi fi
echo "" echo ""
# ── 2. Règle udev NVMe (accès SMART pour le groupe disk) ──────────────────────
UDEV_RULE="/etc/udev/rules.d/99-nanometrics-smart.rules"
cat > "$UDEV_RULE" << 'UDEVRULE'
# Nanometrics: accès groupe disk aux contrôleurs NVMe pour SMART
KERNEL=="nvme[0-9]*", SUBSYSTEM=="nvme", GROUP="disk", MODE="0660"
UDEVRULE
udevadm control --reload-rules
udevadm trigger --subsystem-match=nvme 2>/dev/null || true
ok "Règle udev NVMe installée ($UDEV_RULE)"
echo ""
# ── 3. Détection de l'architecture ──────────────────────────────────────────── # ── 3. Détection de l'architecture ────────────────────────────────────────────
ARCH="$(uname -m)" ARCH="$(uname -m)"
case "$ARCH" in case "$ARCH" in
+6 -1
View File
@@ -17,7 +17,12 @@ ConfigurationDirectoryMode=0755
ProtectSystem=strict ProtectSystem=strict
ProtectHome=read-only ProtectHome=read-only
PrivateTmp=yes PrivateTmp=yes
NoNewPrivileges=yes # CAP_SYS_ADMIN est requis par le noyau pour NVME_IOCTL_ADMIN_CMD (lecture SMART NVMe).
# NoNewPrivileges est retiré car il efface les ambient capabilities sur exec (noyau ≥ 5.2),
# ce qui empêcherait smartctl enfant d'hériter la capability.
# CapabilityBoundingSet borne à la seule cap nécessaire.
CapabilityBoundingSet=CAP_SYS_ADMIN
AmbientCapabilities=CAP_SYS_ADMIN
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+10 -9
View File
@@ -2,7 +2,6 @@ package transport
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net" "net"
@@ -34,19 +33,21 @@ func StartUDP(addr string, handler func(*models.AgentMetrics)) error {
func processUDP(data []byte, src string, handler func(*models.AgentMetrics)) { func processUDP(data []byte, src string, handler func(*models.AgentMetrics)) {
var m models.AgentMetrics var m models.AgentMetrics
if err := json.Unmarshal(data, &m); err != nil { if err := json.Unmarshal(data, &m); err != nil {
preview := "" end := 32
if len(data) > 0 { if len(data) < end {
end := len(data) end = len(data)
if end > 32 {
end = 32
}
preview = fmt.Sprintf(" | src=%s | premiers octets: %x | texte: %q", src, data[:end], data[:end])
} }
log.Printf("[udp] JSON invalide: %v%s", err, preview) log.Printf("[udp] JSON invalide: %v | src=%s | octets: %x", err, src, data[:end])
return return
} }
if m.Hostname == "" { if m.Hostname == "" {
return return
} }
// DEBUG SMART — logguer le payload ASUS complet
if m.Smart != nil {
log.Printf("[udp] SMART reçu de %s: %d disque(s)", m.Hostname, len(m.Smart))
} else {
log.Printf("[udp] payload de %s (v%s): smart=nil hdd=%v", m.Hostname, m.Version, m.HDDTotal)
}
handler(&m) handler(&m)
} }