From 184d2c5108a0851dc9799db51a7a239fdf876a1e Mon Sep 17 00:00:00 2001 From: Nacho Date: Tue, 1 Sep 2026 15:15:02 +0200 Subject: [PATCH 1/5] fix(sudoers): suppress repetitive sudo audit logs for automated collector commands --- deploy/easyzfs.sudoers | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/deploy/easyzfs.sudoers b/deploy/easyzfs.sudoers index 887cfb5..81c17b5 100644 --- a/deploy/easyzfs.sudoers +++ b/deploy/easyzfs.sudoers @@ -2,17 +2,23 @@ # El servicio corre como usuario 'easyzfs' (sin root) y necesita elevar solo # estos binarios (executil antepone `sudo -n` cuando euid != 0). # -# Argumentos restringidos al uso real del código: +# Argumentos restringidos al uso real del codigo: # crontab → solo `-l` (lectura del crontab de root en la vista Tareas); -# sin args, `crontab -e` permitiría ejecutar código como root. +# sin args, `crontab -e` permitiria ejecutar codigo como root. # hdparm → solo `-y /dev/*` (standby de disco en acciones). # udisksctl → solo `power-off -b /dev/*` (apagado de disco; fallback de hdparm). # dd → solo lectura de disco a /dev/null (parpadeo del LED de la -# bahía por actividad I/O; el count fijo evita lecturas largas). +# bahia por actividad I/O; el count fijo evita lecturas largas). # zpool/zfs/smartctl/lsblk necesitan libertad de argumentos (son la API del # almacenamiento); el confinamiento extra vive en easyzfs-sysd y la unit. # -# Instalación: +# Los comandos automatizados del colector generan miles de entradas de sudo al +# dia. EasyZFS ya guarda su propia auditoria en la BD (tabla audit), asi que +# suprimimos aqui el log repetitivo de PAM y el de comandos permitidos para +# no saturar journald. Los intentos fallidos siguen logueandose. +Defaults:easyzfs !pam_session, !log_allowed +# +# Instalacion: # install -m 0440 -o root -g root deploy/easyzfs.sudoers /etc/sudoers.d/easyzfs # visudo -cf /etc/sudoers.d/easyzfs # validar sintaxis antes de recargar easyzfs ALL=(root) NOPASSWD: /usr/sbin/zpool, /usr/sbin/zfs, /usr/sbin/smartctl, /usr/bin/lsblk, /usr/bin/crontab -l, /usr/sbin/hdparm -y /dev/*, /usr/bin/udisksctl power-off -b /dev/*, /usr/bin/dd if=/dev/* of=/dev/null bs=1M count=2048, /usr/local/libexec/easyzfs-sysd From 2fa1d22a3e14f038ea2ce8130669495131cc24ea Mon Sep 17 00:00:00 2001 From: Nacho Date: Tue, 1 Sep 2026 15:15:02 +0200 Subject: [PATCH 2/5] feat(config): make zpool collector interval configurable via EASYZFS_ZPOOL_INTERVAL --- deploy/install.sh | 2 +- internal/config/config.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/deploy/install.sh b/deploy/install.sh index fdf07e8..da15bc0 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -966,7 +966,7 @@ DEMO=1" info "MODO DEMO activado (DEMO=1): datos de muestra; las mutaciones responden 403 demo_mode." info "Para pasar a producción: quita DEMO=1 de ${ENV_FILE} y reinicia el servicio." else - info "Opcionales que puedes añadir: COOKIE_SECURE=1 (tras proxy TLS), RETENTION_DAYS=30, DEMO=1, MOCK=1." + info "Opcionales que puedes añadir: COOKIE_SECURE=1 (tras proxy TLS), RETENTION_DAYS=30, EASYZFS_ZPOOL_INTERVAL=60 (segundos), DEMO=1, MOCK=1." fi } diff --git a/internal/config/config.go b/internal/config/config.go index 7128a38..a555421 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,10 @@ type Config struct { SyslogPort int // SYSLOG_PORT (def 514) SyslogProto string // SYSLOG_PROTO: udp | tcp (def udp) SyslogFacility int // SYSLOG_FACILITY (def 1 = user) + + // Intervalo del colector principal de ZFS (#124). Valores altos reducen + // el numero de comandos sudo y el volumen de logs de auditoria. + ZpoolInterval time.Duration // EASYZFS_ZPOOL_INTERVAL en segundos (def 60) } // DataDir — directorio de datos del daemon (deriva de DB_PATH): ahí viven la @@ -102,6 +106,8 @@ func Load() *Config { SyslogPort: envInt("SYSLOG_PORT", 514), SyslogProto: env("SYSLOG_PROTO", "udp"), SyslogFacility: envInt("SYSLOG_FACILITY", 1), + + ZpoolInterval: time.Duration(envInt("EASYZFS_ZPOOL_INTERVAL", 60)) * time.Second, } if cfg.Demo { cfg.Mock = true // demo implica colectores mock From ebd54b83ea346a81ed3da952048d526ed62fbb9e Mon Sep 17 00:00:00 2001 From: Nacho Date: Tue, 1 Sep 2026 15:15:24 +0200 Subject: [PATCH 3/5] refactor(collectors): cache stable pool properties and throttle trim checks --- internal/collectors/collectors.go | 2 +- internal/collectors/zpool.go | 89 +++++++++++++++++++++---------- 2 files changed, 62 insertions(+), 29 deletions(-) diff --git a/internal/collectors/collectors.go b/internal/collectors/collectors.go index 5a21a14..07185de 100644 --- a/internal/collectors/collectors.go +++ b/internal/collectors/collectors.go @@ -63,7 +63,7 @@ func Build(cfg *config.Config, d *sql.DB, h *hub.Hub, al *alerts.Alerter) (*Prov m := NewMock(h, al) return &Providers{Pools: m, Disks: m, SysTimers: m, Perf: m, Caps: m}, []Collector{m, mant} } - zc := NewZpoolCollector(d, h, al) + zc := NewZpoolCollector(d, h, al, cfg.ZpoolInterval) sc := NewSensorsCollector(h) smc := NewSmartCollector(d, h, al, sc) ssc := NewSchedSysCollector() diff --git a/internal/collectors/zpool.go b/internal/collectors/zpool.go index b206cc4..ae3036d 100644 --- a/internal/collectors/zpool.go +++ b/internal/collectors/zpool.go @@ -23,11 +23,13 @@ import ( ) const ( - zpoolInterval = 30 * time.Second - zpoolMaxBackoff = 5 * time.Minute - seriesInterval = 10 * time.Minute // persistir series con esta cadencia mínima - historyTTL = 10 * time.Minute // re-leer 'zpool history' como máximo con esta cadencia - historyTimeout = 90 * time.Second // historiales grandes (bigtank: ~20 s / 275 MB) + zpoolIntervalDef = 60 * time.Second + zpoolMaxBackoff = 5 * time.Minute + seriesInterval = 10 * time.Minute // persistir series con esta cadencia minima + historyTTL = 10 * time.Minute // re-leer 'zpool history' como maximo con esta cadencia + historyTimeout = 90 * time.Second // historiales grandes (bigtank: ~20 s / 275 MB) + propTTL = 5 * time.Minute // propiedades estables: autotrim, checkpoint, compressratio + trimTTL = 2 * time.Minute // estado TRIM no cambia tan rapido; reduce llamadas -t ) // ZpoolCollector — caché de pools, datasets y snapshots. @@ -49,24 +51,36 @@ type ZpoolCollector struct { prevPct map[string]int lastSeries map[string]time.Time - // refreshCh despierta el bucle Run tras una mutación (autotrim, trim…): - // sin él la UI vería el valor antiguo hasta el próximo tick de 30 s. - // Buffer 1 = debounce: una ráfaga de mutaciones produce UNA recolecta. + // Intervalo entre recolectas periodicas (configurable; #124). + interval time.Duration + + // Cache de propiedades estables y de trim para no repetir comandos en + // cada tick del colector. + lastPropsAt map[string]time.Time + + // refreshCh despierta el bucle Run tras una mutacion (autotrim, trim...): + // sin el la UI veria el valor antiguo hasta el proximo tick. + // Buffer 1 = debounce: una rafaga de mutaciones produce UNA recolecta. refreshCh chan struct{} } -// NewZpoolCollector crea el colector. -func NewZpoolCollector(d *sql.DB, h *hub.Hub, al *alerts.Alerter) *ZpoolCollector { +// NewZpoolCollector crea el colector. interval=0 usa el default de 60 s. +func NewZpoolCollector(d *sql.DB, h *hub.Hub, al *alerts.Alerter, interval time.Duration) *ZpoolCollector { + if interval <= 0 { + interval = zpoolIntervalDef + } return &ZpoolCollector{ - db: d, - h: h, - al: al, - prevStatus: map[string]string{}, - prevPct: map[string]int{}, - lastSeries: map[string]time.Time{}, - history: map[string][]model.HistoryEntry{}, - historyAt: map[string]time.Time{}, - refreshCh: make(chan struct{}, 1), + db: d, + h: h, + al: al, + interval: interval, + prevStatus: map[string]string{}, + prevPct: map[string]int{}, + lastSeries: map[string]time.Time{}, + lastPropsAt: map[string]time.Time{}, + history: map[string][]model.HistoryEntry{}, + historyAt: map[string]time.Time{}, + refreshCh: make(chan struct{}, 1), } } @@ -86,7 +100,7 @@ func (c *ZpoolCollector) RefreshSoon() { // Run — bucle con ticker, backoff tras 3 fallos seguidos (patrón del skill). func (c *ZpoolCollector) Run(ctx context.Context) { - interval := zpoolInterval + interval := c.interval t := time.NewTicker(interval) defer t.Stop() if err := c.collectOnce(ctx); err != nil { @@ -118,9 +132,9 @@ func (c *ZpoolCollector) Run(ctx context.Context) { c.stale = true interval = min(2*interval, zpoolMaxBackoff) t.Reset(interval) - } else if interval != zpoolInterval { + } else if interval != c.interval { c.stale = false - interval = zpoolInterval + interval = c.interval t.Reset(interval) } } @@ -174,11 +188,12 @@ func (c *ZpoolCollector) collectOnce(ctx context.Context) error { if err != nil { return err } + now := time.Now() for i := range pools { c.fillStatus(ctx, &pools[i]) // tolerante: degrada, no falla la pasada - c.fillTrim(ctx, &pools[i]) // progreso de TRIM (zpool status -t) - c.fillCompressRatio(ctx, &pools[i]) - c.fillPoolProps(ctx, &pools[i]) + c.fillTrim(ctx, &pools[i], now) + c.fillCompressRatio(ctx, &pools[i], now) + c.fillPoolProps(ctx, &pools[i], now) c.resolveVdevPaths(ctx, &pools[i]) } history := map[string][]model.HistoryEntry{} @@ -265,7 +280,12 @@ func (c *ZpoolCollector) listPools(ctx context.Context) ([]model.Pool, error) { // fillPoolProps — propiedades del pool autotrim y checkpoint // ('zpool get -Hp -o property,value autotrim,checkpoint '). // checkpoint vale "-" cuando no hay checkpoint activo. -func (c *ZpoolCollector) fillPoolProps(ctx context.Context, p *model.Pool) { +// Estas propiedades cambian muy poco: se cachean con TTL para reducir sudo. +func (c *ZpoolCollector) fillPoolProps(ctx context.Context, p *model.Pool, now time.Time) { + key := "props:" + p.Name + if time.Since(c.lastPropsAt[key]) < propTTL { + return + } out, err := executil.Run(ctx, 5*time.Second, "zpool", "get", "-Hp", "-o", "property,value", "autotrim,checkpoint", p.Name) if err != nil { @@ -283,6 +303,7 @@ func (c *ZpoolCollector) fillPoolProps(ctx context.Context, p *model.Pool) { p.Checkpoint = f[1] != "-" && f[1] != "" } } + c.lastPropsAt[key] = now } // fetchHistory — 'zpool history -i ' parseado EN STREAMING (nil si @@ -322,7 +343,12 @@ func (c *ZpoolCollector) History(name string) []model.HistoryEntry { } // fillCompressRatio — compressratio del dataset raíz del pool como ratio del pool. -func (c *ZpoolCollector) fillCompressRatio(ctx context.Context, p *model.Pool) { +// Propiedad estable: se cachea con TTL para reducir llamadas a zfs get. +func (c *ZpoolCollector) fillCompressRatio(ctx context.Context, p *model.Pool, now time.Time) { + key := "compress:" + p.Name + if time.Since(c.lastPropsAt[key]) < propTTL { + return + } out, err := executil.Run(ctx, 5*time.Second, "zfs", "get", "-Hp", "-o", "value", "compressratio", p.Name) if err != nil { @@ -332,6 +358,7 @@ func (c *ZpoolCollector) fillCompressRatio(ctx context.Context, p *model.Pool) { if n, err := strconv.ParseFloat(v, 64); err == nil { p.CompRatio = n } + c.lastPropsAt[key] = now } // --- zpool status: JSON (OpenZFS ≥2.2) con fallback a texto --- @@ -667,12 +694,18 @@ func (c *ZpoolCollector) parseStatusText(out string, p *model.Pool) { // fillTrim — progreso de TRIM ('zpool status -t '; la salida normal no // lo muestra). Solo rellena Scrub si el pool no tiene scrub/resilver en curso // (el scan de datos manda sobre el trim en la representación unificada). -func (c *ZpoolCollector) fillTrim(ctx context.Context, p *model.Pool) { +// Se ejecuta con TTL: reduce llamadas sudo cuando no hay trim activo. +func (c *ZpoolCollector) fillTrim(ctx context.Context, p *model.Pool, now time.Time) { + key := "trim:" + p.Name + if time.Since(c.lastPropsAt[key]) < trimTTL { + return + } out, err := executil.Run(ctx, 15*time.Second, "zpool", "status", "-t", p.Name) if err != nil { return } c.parseTrimStatus(string(out), p) + c.lastPropsAt[key] = now } // parseTrimStatus — líneas 'scan:' de 'zpool status -t': From 7ffcf0afb0531b6ac1cbc462e066b8ae0f46e462 Mon Sep 17 00:00:00 2001 From: Nacho Date: Tue, 1 Sep 2026 15:15:37 +0200 Subject: [PATCH 4/5] test(collectors): add TTL tests for cached pool properties --- internal/collectors/zpool_test.go | 119 ++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 7 deletions(-) diff --git a/internal/collectors/zpool_test.go b/internal/collectors/zpool_test.go index 7a55df9..33b212e 100644 --- a/internal/collectors/zpool_test.go +++ b/internal/collectors/zpool_test.go @@ -1,10 +1,13 @@ -// zpool_test.go — parseo de status y resolución de vdevs UUID→dispositivo. +// zpool_test.go — parseo de status y resolucion de vdevs UUID→dispositivo. package collectors import ( "context" + "os" + "path/filepath" "strings" "testing" + "time" "easyzfs/internal/model" ) @@ -19,9 +22,9 @@ func TestParseStatusJSONVdevs(t *testing.T) { }} }}}}`) p := &model.Pool{Name: "tank", Vdevs: []model.Vdev{}} - c := &ZpoolCollector{} + c := &ZpoolCollector{lastPropsAt: map[string]time.Time{}} if !c.parseStatusJSON(out, p) { - t.Fatal("parseStatusJSON devolvió false") + t.Fatal("parseStatusJSON devolvio false") } if len(p.Vdevs) != 2 { t.Fatalf("vdevs=%d, esperaba 2", len(p.Vdevs)) @@ -35,7 +38,7 @@ func TestParseStatusJSONVdevs(t *testing.T) { } func TestResolveVdevPaths(t *testing.T) { - c := &ZpoolCollector{} + c := &ZpoolCollector{lastPropsAt: map[string]time.Time{}} p := &model.Pool{Name: "tank", Vdevs: []model.Vdev{ {Dev: "sdb1", Status: "ONLINE"}, {Dev: "nvme0n1p2", Status: "ONLINE"}, @@ -68,9 +71,9 @@ func TestParseStatusJSONResilver(t *testing.T) { }, "scan_stats":{"function":"RESILVER","state":"SCANNING","to_examine":"35.2T","examined":"3.52T","pass_start":"1785606676","errors":"0"}}}}`) p := &model.Pool{Name: "tank", Vdevs: []model.Vdev{}} - c := &ZpoolCollector{} + c := &ZpoolCollector{lastPropsAt: map[string]time.Time{}} if !c.parseStatusJSON(out, p) { - t.Fatal("parseStatusJSON devolvió false") + t.Fatal("parseStatusJSON devolvio false") } if p.Scrub.State != "running" || p.Scrub.Kind != "resilver" { t.Fatalf("scrub=%+v, esperaba running resilver", p.Scrub) @@ -92,7 +95,7 @@ func TestParseHumanSize(t *testing.T) { } } if _, ok := parseHumanSize("-"); ok { - t.Error("'-' no debería parsear") + t.Error("'-' no deberia parsear") } } @@ -106,3 +109,105 @@ func TestReUUID(t *testing.T) { } } } + +// fakePoolBin crea un zpool/zfs falso que cuenta invocaciones en un log. +func fakePoolBin(t *testing.T) (dir, logFile string) { + t.Helper() + dir = t.TempDir() + logFile = filepath.Join(dir, "calls.log") + + // zpool falso: anota los args y sale 0. + zpool := "#!/bin/sh\necho \"$@\" >> " + logFile + "\nexit 0\n" + // zfs falso: anota los args y sale 0. + zfs := "#!/bin/sh\necho \"$@\" >> " + logFile + "\nexit 0\n" + // sudo falso: executil antepone 'sudo -n'; lo ignoramos. + sudo := "#!/bin/sh\nwhile [ $# -gt 0 ]; do case \"$1\" in -*) shift;; *) break;; esac; done\nexec \"$@\"\n" + for name, body := range map[string]string{"zpool": zpool, "zfs": zfs, "sudo": sudo} { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return dir, logFile +} + +func readCalls(t *testing.T, logFile string) []string { + t.Helper() + b, err := os.ReadFile(logFile) + if err != nil { + return nil + } + return strings.Split(strings.TrimSpace(string(b)), "\n") +} + +func TestFillPoolPropsTTL(t *testing.T) { + _, logFile := fakePoolBin(t) + c := &ZpoolCollector{lastPropsAt: map[string]time.Time{}} + p := &model.Pool{Name: "tank"} + now := time.Now() + + c.fillPoolProps(context.Background(), p, now) + if len(readCalls(t, logFile)) != 1 { + t.Fatalf("esperaba 1 llamada, hay %d", len(readCalls(t, logFile))) + } + + // Segunda llamada inmediata: no deberia ejecutar zpool por TTL. + c.fillPoolProps(context.Background(), p, now) + if calls := readCalls(t, logFile); len(calls) != 1 { + t.Fatalf("esperaba 1 llamada tras TTL, hay %d", len(calls)) + } + + // Tras TTL: deberia volver a llamar. + c.lastPropsAt["props:tank"] = now.Add(-propTTL - time.Second) + c.fillPoolProps(context.Background(), p, now.Add(propTTL+time.Second)) + if calls := readCalls(t, logFile); len(calls) != 2 { + t.Fatalf("esperaba 2 llamadas tras expirar TTL, hay %d", len(calls)) + } +} + +func TestFillCompressRatioTTL(t *testing.T) { + _, logFile := fakePoolBin(t) + c := &ZpoolCollector{lastPropsAt: map[string]time.Time{}} + p := &model.Pool{Name: "tank"} + now := time.Now() + + c.fillCompressRatio(context.Background(), p, now) + if len(readCalls(t, logFile)) != 1 { + t.Fatalf("esperaba 1 llamada, hay %d", len(readCalls(t, logFile))) + } + + c.fillCompressRatio(context.Background(), p, now) + if calls := readCalls(t, logFile); len(calls) != 1 { + t.Fatalf("esperaba 1 llamada tras TTL, hay %d", len(calls)) + } + + c.lastPropsAt["compress:tank"] = now.Add(-propTTL - time.Second) + c.fillCompressRatio(context.Background(), p, now.Add(propTTL+time.Second)) + if calls := readCalls(t, logFile); len(calls) != 2 { + t.Fatalf("esperaba 2 llamadas tras expirar TTL, hay %d", len(calls)) + } +} + +func TestFillTrimTTL(t *testing.T) { + _, logFile := fakePoolBin(t) + c := &ZpoolCollector{lastPropsAt: map[string]time.Time{}} + p := &model.Pool{Name: "ssd"} + now := time.Now() + + c.fillTrim(context.Background(), p, now) + if len(readCalls(t, logFile)) != 1 { + t.Fatalf("esperaba 1 llamada, hay %d", len(readCalls(t, logFile))) + } + + c.fillTrim(context.Background(), p, now) + if calls := readCalls(t, logFile); len(calls) != 1 { + t.Fatalf("esperaba 1 llamada tras TTL, hay %d", len(calls)) + } + + c.lastPropsAt["trim:ssd"] = now.Add(-trimTTL - time.Second) + c.fillTrim(context.Background(), p, now.Add(trimTTL+time.Second)) + if calls := readCalls(t, logFile); len(calls) != 2 { + t.Fatalf("esperaba 2 llamadas tras expirar TTL, hay %d", len(calls)) + } +} From 6e267dc30e51f417e6c4563a07dba13c494057d9 Mon Sep 17 00:00:00 2001 From: Nacho Date: Tue, 1 Sep 2026 16:58:26 +0200 Subject: [PATCH 5/5] docs(changelog): v2.9.20 (#124) --- README.es.md | 4 ++++ README.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.es.md b/README.es.md index 9d88225..c90a8eb 100644 --- a/README.es.md +++ b/README.es.md @@ -367,6 +367,10 @@ Dependencias Go (mantenidas a 2 a propósito): ## Registro de cambios +### v2.9.20 + +- **Reduce el volumen de logs sudo/journald de los colectores periódicos (#124)**: el servicio de EasyZFS generaba decenas de miles de entradas sudo al día porque cada comando `zpool`/`zfs` que ejecutaban los colectores se logueaba en `sudo` como comando permitido. Se añade `Defaults:easyzfs !pam_session, !log_allowed` al sudoers para que los comandos permitidos no se logueen; los intentos fallidos siguen registrándose. Se complementa con cachés TTL en `ZpoolCollector` para propiedades estables del pool (`autotrim`, `checkpoint`, `compressratio`) y para `zpool status -t`, y una nueva variable de entorno `EASYZFS_ZPOOL_INTERVAL` (60 s por defecto) para controlar el intervalo del colector. Validado en nodos reales de Proxmox: `journalctl -g 'easyzfs :'` pasa de miles de entradas al día a casi cero mientras el estado de pools, datasets y snapshots sigue actualizándose. + ### v2.9.19 - **Fix del instalador: error de variable no definida resuelto**: el script de instalación ya no falla con el error "existing_webhook: unbound variable" durante la configuración. El WEBHOOK_SECRET ahora se lee correctamente del archivo env. diff --git a/README.md b/README.md index 8f6283d..c720634 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,10 @@ Go dependencies (kept to 2 on purpose): ## Changelog +### v2.9.20 + +- **Reduce sudo/journald log volume from periodic collectors (#124)**: the EasyZFS service generated tens of thousands of sudo log entries per day because every `zpool`/`zfs` command run by the collectors was logged by `sudo` as an allowed command. Add `Defaults:easyzfs !pam_session, !log_allowed` to the sudoers file so allowed commands are no longer logged; failed attempts still are. Complement this with TTL caches in `ZpoolCollector` for stable pool properties (`autotrim`, `checkpoint`, `compressratio`) and for `zpool status -t`, plus a new `EASYZFS_ZPOOL_INTERVAL` environment variable (default 60 s) to control the collector tick interval. Validated on real Proxmox nodes: `journalctl -g 'easyzfs :'` drops from thousands of entries per day to near zero while pool status, datasets and snapshots keep updating. + ### v2.9.19 - **Installer fix: unbound variable error resolved**: the installer script no longer fails with an "existing_webhook: unbound variable" error during configuration. The WEBHOOK_SECRET is now correctly read from the env file.