commit 09159a5f31914b36fb6a14de0800fefd1f3b0baf Author: a.gritsenko Date: Sat Sep 19 10:24:34 2026 +0300 Initial import: nasmon health monitor, split out of home_nas Go service (nasmon/) plus nfpm packaging (packaging/), carried over from the home_nas monorepo. Adds rpm packaging alongside the existing deb build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea Actions workflow that builds both formats on every push. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8a50094 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Files that end up on the Linux server must keep LF endings +*.sh text eol=lf +*.yml text eol=lf +*.json text eol=lf +.env* text eol=lf +*.ps1 text eol=crlf +*.go text eol=lf +*.yaml text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf +*.md text eol=lf +*.service text eol=lf +go.mod text eol=lf +go.sum text eol=lf diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..b4aea9e --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,37 @@ +name: build + +on: + push: + branches: ["**"] + tags: ["v*"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: vet & test + run: | + go vet ./... + go test ./... + + - name: install nfpm + run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest + + - name: build packages + run: | + export PATH="$PATH:$(go env GOPATH)/bin" + ./build.sh + + - uses: actions/upload-artifact@v4 + with: + name: nasmon-packages + path: | + dist/*.deb + dist/*.rpm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba85743 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/nasmon +/nasmon.exe +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e72d81 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# nasmon + +Lightweight NAS health monitor written in Go. Web dashboard (CPU, memory +incl. ZFS ARC, temperatures, physical disks with SMART, ZFS pools and +datasets) with periodic SMART/ZFS checks and e-mail alerts, plus SMB/NFS +share management from the UI. + +* Web UI: `http://:8090` +* Settings: `http://:8090/settings` (also `/etc/nasmon/config.json`) +* State: `/var/lib/nasmon/nasmon.db` (SMART history, alert log) +* Service: `systemctl status nasmon` / `journalctl -u nasmon -f` +* JSON API: `/api/status`, `/api/history?hours=24`, `/api/disks//history?days=30` + +No authentication — designed for a LAN-only NAS (the `/shares` page can +create SMB/NFS users and exports, so keep it off the public internet). + +See [packaging/README.md](packaging/README.md) for the full operational +doc (ZFS dataset management, SMB/NFS shares, alert lifecycle) — it ships +inside the package as `/usr/share/doc/nasmon/README.md`. + +## Build + +Go 1.22, [nfpm](https://nfpm.goreleaser.com/) (no `rpmbuild`/`dpkg-deb` +needed — nfpm builds both formats natively). + +```powershell +# Windows +.\build.ps1 # dist\nasmon__amd64.deb + dist\nasmon-.x86_64.rpm +.\build.ps1 -Test # go vet + go test first +.\build.ps1 -Version 0.2.0 +``` + +```bash +# Linux/WSL/CI +./build.sh +TEST=1 ./build.sh +VERSION=0.2.0 ./build.sh +``` + +One-time tool setup: + +``` +go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest +``` + +## Packaging + +`packaging/nfpm.yaml` builds both formats from one spec: + +- **.deb** (Ubuntu/Debian): depends on `smartmontools`, `zfsutils-linux`, `util-linux`; + recommends `samba`, `nfs-kernel-server`, `wsdd` for the `/shares` page. +- **.rpm** (Fedora/RHEL/Rocky, `dnf`): depends on `smartmontools`, `util-linux`; + recommends `samba`, `nfs-utils`, `wsdd`. ZFS is **not** a hard dependency on + rpm — RHEL-family distros have no native ZFS package; install the + [OpenZFS](https://openzfs.github.io/openzfs-docs/Getting%20Started/RHEL-based%20distro/index.html) + repo yourself if you need the `/zfs` page's pool/dataset features. + +Both formats install the same systemd unit (`packaging/nasmon.service`, run +as root — it needs `smartctl`/`zpool`), just at their respective conventional +paths (`/lib/systemd/system` on deb, `/usr/lib/systemd/system` on rpm), plus +the bundled `wsdd.service` unit (Ubuntu/Fedora `wsdd` packages ship no unit +of their own), default config at `/etc/nasmon/config.json`, and +`postinst.sh`/`prerm.sh`/`postrm.sh` maintainer scripts (written to run +correctly under both dpkg and rpm argument conventions). + +## CI + +`.gitea/workflows/build.yml` runs `go vet`/`go test` and builds both +packages on every push, uploading `dist/*.deb` and `dist/*.rpm` as build +artifacts. diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..9081323 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,87 @@ +<# +.SYNOPSIS + Builds the nasmon binary (linux/amd64, static) and packs it into a .deb and .rpm with nfpm. + Output: dist\nasmon__amd64.deb and dist\nasmon-..rpm + + One-time setup: + winget install GoLang.Go + go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest + +.EXAMPLE + .\build.ps1 # version from git tag, or 0.1.0+ + .\build.ps1 -Version 0.2.0 + .\build.ps1 -Test # run go vet + go test first +#> +[CmdletBinding()] +param( + [string]$Version, + [switch]$Test +) + +$ErrorActionPreference = "Stop" +$root = $PSScriptRoot +$src = $root +$dist = Join-Path $root "dist" + +# tools: go and nfpm may live outside PATH in a fresh shell +$env:Path = "$env:Path;C:\Program Files\Go\bin;$env:USERPROFILE\go\bin" +foreach ($tool in "go", "nfpm") { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { + throw "$tool not found - see the setup notes at the top of build.ps1" + } +} + +if (-not $Version) { + $Version = $null + if (Get-Command git -ErrorAction SilentlyContinue) { + Push-Location $root + try { $tag = git describe --tags --always --dirty 2>$null } catch { $tag = $null } + Pop-Location + if ($tag -and $tag -match '^v?(\d+\.\d+\.\d+)(-(\d+)-g([0-9a-f]+))?(-dirty)?$') { + $Version = $Matches[1] + if ($Matches[3]) { $Version += "+git$($Matches[3]).$($Matches[4])" } + if ($Matches[5]) { $Version += ".dirty" } + } + } + if (-not $Version) { $Version = "0.1.0+" + (Get-Date -Format "yyyyMMdd.HHmm") } +} + +New-Item -ItemType Directory -Force $dist | Out-Null + +if ($Test) { + Write-Host "==> go vet / go test" -ForegroundColor Green + Push-Location $src + try { + go vet ./...; if ($LASTEXITCODE -ne 0) { throw "go vet failed" } + go test ./...; if ($LASTEXITCODE -ne 0) { throw "go test failed" } + } finally { Pop-Location } +} + +Write-Host "==> Building nasmon $Version (linux/amd64)" -ForegroundColor Green +$env:GOOS = "linux"; $env:GOARCH = "amd64"; $env:CGO_ENABLED = "0" +Push-Location $src +try { + go build -trimpath -ldflags "-s -w -X main.version=$Version" -o (Join-Path $dist "nasmon") ./cmd/nasmon + if ($LASTEXITCODE -ne 0) { throw "go build failed" } +} finally { + Pop-Location + Remove-Item Env:GOOS, Env:GOARCH, Env:CGO_ENABLED -ErrorAction SilentlyContinue +} + +Write-Host "==> Packaging .deb" -ForegroundColor Green +Get-ChildItem $dist -Filter "nasmon_*.deb" | Remove-Item -Force +$env:NASMON_VERSION = $Version +Push-Location (Join-Path $root "packaging") +try { + nfpm package -f nfpm.yaml -p deb -t $dist + if ($LASTEXITCODE -ne 0) { throw "nfpm deb failed" } + + Write-Host "==> Packaging .rpm" -ForegroundColor Green + Get-ChildItem $dist -Filter "nasmon-*.rpm" | Remove-Item -Force + nfpm package -f nfpm.yaml -p rpm -t $dist + if ($LASTEXITCODE -ne 0) { throw "nfpm rpm failed" } +} finally { Pop-Location } + +foreach ($pkg in (Get-ChildItem $dist -Filter "nasmon*" -Include "*.deb", "*.rpm")) { + Write-Host "==> $($pkg.FullName) ($([math]::Round($pkg.Length / 1MB, 1)) MB)" -ForegroundColor Green +} diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..dfd9ce1 --- /dev/null +++ b/build.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Builds the nasmon binary (linux/amd64, static) and packs it into .deb and .rpm with nfpm. +# Output: dist/nasmon__amd64.deb and dist/nasmon-..rpm +# +# One-time setup: +# go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest +# +# Usage: +# ./build.sh # version from git tag, or 0.1.0+ +# VERSION=0.2.0 ./build.sh +# TEST=1 ./build.sh # run go vet + go test first + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +dist="$root/dist" + +for tool in go nfpm; do + command -v "$tool" >/dev/null 2>&1 || { echo "$tool not found - see the setup notes at the top of build.sh" >&2; exit 1; } +done + +version="${VERSION:-}" +if [ -z "$version" ] && command -v git >/dev/null 2>&1; then + tag="$(cd "$root" && git describe --tags --always --dirty 2>/dev/null || true)" + if [[ "$tag" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)(-([0-9]+)-g([0-9a-f]+))?(-dirty)?$ ]]; then + version="${BASH_REMATCH[1]}" + [ -n "${BASH_REMATCH[3]}" ] && version+="+git${BASH_REMATCH[3]}.${BASH_REMATCH[4]}" + [ -n "${BASH_REMATCH[5]}" ] && version+=".dirty" + fi +fi +[ -z "$version" ] && version="0.1.0+$(date +%Y%m%d.%H%M)" + +mkdir -p "$dist" + +if [ "${TEST:-}" = "1" ]; then + echo "==> go vet / go test" + (cd "$root" && go vet ./... && go test ./...) +fi + +echo "==> Building nasmon $version (linux/amd64)" +(cd "$root" && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w -X main.version=$version" -o "$dist/nasmon" ./cmd/nasmon) + +echo "==> Packaging .deb" +rm -f "$dist"/nasmon_*.deb +(cd "$root/packaging" && NASMON_VERSION="$version" nfpm package -f nfpm.yaml -p deb -t "$dist") + +echo "==> Packaging .rpm" +rm -f "$dist"/nasmon-*.rpm +(cd "$root/packaging" && NASMON_VERSION="$version" nfpm package -f nfpm.yaml -p rpm -t "$dist") + +for pkg in "$dist"/nasmon*.deb "$dist"/nasmon*.rpm; do + [ -e "$pkg" ] || continue + size_mb=$(du -m "$pkg" | cut -f1) + echo "==> $pkg (${size_mb} MB)" +done diff --git a/cmd/nasmon/main.go b/cmd/nasmon/main.go new file mode 100644 index 0000000..6b2a5e0 --- /dev/null +++ b/cmd/nasmon/main.go @@ -0,0 +1,84 @@ +// nasmon — lightweight NAS health monitor: web dashboard + SMART/ZFS alerts by e-mail. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "nasmon/internal/alert" + "nasmon/internal/config" + "nasmon/internal/history" + "nasmon/internal/sched" + "nasmon/internal/shares" + "nasmon/internal/store" + "nasmon/internal/web" +) + +var version = "dev" + +func main() { + listen := flag.String("listen", ":8090", "HTTP listen address") + dataDir := flag.String("data", "/var/lib/nasmon", "directory for the SQLite database") + cfgPath := flag.String("config", "/etc/nasmon/config.json", "settings file (created on first save)") + showVer := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVer { + fmt.Println("nasmon", version) + return + } + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + + cfg, err := config.Load(*cfgPath) + if err != nil { + log.Fatalf("config: %v", err) + } + if err := os.MkdirAll(*dataDir, 0o750); err != nil { + log.Fatalf("data dir: %v", err) + } + st, err := store.Open(filepath.Join(*dataDir, "nasmon.db")) + if err != nil { + log.Fatalf("database: %v", err) + } + defer st.Close() + + sh, err := shares.Load(filepath.Join(filepath.Dir(*cfgPath), "shares.json")) + if err != nil { + log.Fatalf("shares config: %v", err) + } + + ring := history.New(24 * 60 * 6) // 24 h at 10 s + am := &alert.Manager{Store: st, Config: cfg, Send: alert.SendMail} + sc := sched.New(cfg, st, ring, am, version) + sc.LoadCachedSmart() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go sc.Run(ctx) + + srv := &http.Server{ + Addr: *listen, + Handler: web.New(cfg, st, ring, sc, sh), + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + log.Printf("nasmon %s listening on %s", version, *listen) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("http: %v", err) + } + }() + + <-ctx.Done() + log.Print("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7a2ab1d --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module nasmon + +go 1.22 + +require modernc.org/sqlite v1.34.4 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c337282 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go new file mode 100644 index 0000000..ed4ad25 --- /dev/null +++ b/internal/alert/alert_test.go @@ -0,0 +1,305 @@ +package alert + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "nasmon/internal/collect" + "nasmon/internal/config" + "nasmon/internal/store" +) + +func keys(ev []Event) []string { + out := make([]string, len(ev)) + for i, e := range ev { + out[i] = e.Key + } + return out +} + +func has(ev []Event, key string) bool { + for _, e := range ev { + if e.Key == key { + return true + } + } + return false +} + +func TestEvaluateRules(t *testing.T) { + now := time.Date(2025, 9, 13, 12, 0, 0, 0, time.Local) + old := now.Add(-60 * 24 * time.Hour) + th := config.Defaults().Thresholds + in := Input{ + Now: now, Th: th, PoolsOK: true, RootOK: true, + Smarts: []collect.Smart{ + {Device: "/dev/sda", Serial: "A", Model: "HDD", Supported: true, Healthy: true, HasTemp: true, TempC: 55, + Attrs: []collect.SmartAttr{{ID: 5, Raw: 12}, {ID: 199, Raw: 5}}}, + {Device: "/dev/sdb", Serial: "B", Model: "HDD", Supported: true, Healthy: false, HasTemp: true, TempC: 30, + LastTest: &collect.SelfTest{Type: "Short offline", Status: "Completed: read failure", Passed: false}}, + {Device: "/dev/nvme0n1", Serial: "N", Model: "NVMe", Supported: true, Healthy: true, + NVMe: &collect.NVMeHealth{PercentageUsed: 95, AvailableSpare: 100}}, + {Device: "/dev/sdc", Serial: "C", Model: "HDD", Supported: true, Healthy: true, HasTemp: true, TempC: 35}, + }, + Prev: map[string]*collect.Smart{"A": {Serial: "A", Attrs: []collect.SmartAttr{{ID: 199, Raw: 3}}}}, + Disks: []collect.Disk{{Serial: "A"}, {Serial: "B"}, {Serial: "N"}, {Serial: "C"}}, + Seen: []store.SeenDisk{{Serial: "A"}, {Serial: "GONE", Model: "Old", Device: "/dev/sdz"}}, + Pools: []collect.Pool{ + {Name: "good", Health: "ONLINE", CapPct: 40, Size: 100, Free: 60, LastScrub: &now, Errors: "No known data errors"}, + {Name: "bad", Health: "DEGRADED", CapPct: 90, Size: 100, Free: 10, LastScrub: &old, + Vdevs: []collect.Vdev{{Name: "sda", Cksum: 2}}, Errors: "No known data errors"}, + {Name: "never", Health: "ONLINE", CapPct: 10, Size: 100, Free: 90}, + }, + CPUTemp: 85, HasCPU: true, + RootFS: collect.FSUsage{Total: 100, Free: 5}, + } + ev := Evaluate(in) + want := []string{ + "smart:A:attr", "smart:A:crc", "smart:A:temp", + "smart:B:health", "smart:B:selftest", + "smart:N:nvme", + "disk:GONE:missing", + "zfs:bad:health", "zfs:bad:errors", "zfs:bad:capacity", "zfs:bad:scrub", + "zfs:never:scrub", + "sys:cpu:temp", "sys:rootfs:space", + } + for _, k := range want { + if !has(ev, k) { + t.Errorf("missing event %s; got %v", k, keys(ev)) + } + } + if len(ev) != len(want) { + t.Errorf("got %d events, want %d: %v", len(ev), len(want), keys(ev)) + } + for _, e := range ev { + if strings.HasPrefix(e.Key, "smart:C:") || strings.HasPrefix(e.Key, "zfs:good:") { + t.Errorf("unexpected event %s", e.Key) + } + if e.Key == "smart:A:attr" && e.Level != LevelCrit { + t.Errorf("reallocated sectors must be crit, got %s", e.Level) + } + } + // timeouts / reported-uncorrect alone are only a warning + ev = Evaluate(Input{Now: now, Th: th, Smarts: []collect.Smart{{Serial: "S", Supported: true, Healthy: true, + Attrs: []collect.SmartAttr{{ID: 187, Raw: 6}, {ID: 188, Raw: 3}}}}}) + if len(ev) != 1 || ev[0].Key != "smart:S:attr" || ev[0].Level != LevelWarn { + t.Errorf("187/188 only: %+v", ev) + } +} + +func TestEvaluateDisabledThresholds(t *testing.T) { + th := config.Thresholds{} // all zero = disabled + in := Input{Now: time.Now(), Th: th, PoolsOK: true, RootOK: true, HasCPU: true, CPUTemp: 99, + Smarts: []collect.Smart{{Serial: "A", Supported: true, Healthy: true, HasTemp: true, TempC: 99}}, + Pools: []collect.Pool{{Name: "p", Health: "ONLINE", CapPct: 99, Errors: "No known data errors"}}, + RootFS: collect.FSUsage{Total: 100, Free: 1}} + if ev := Evaluate(in); len(ev) != 0 { + t.Errorf("thresholds disabled but got %v", keys(ev)) + } +} + +type mailSink struct { + subjects []string + bodies []string + fail bool +} + +func (m *mailSink) send(_ config.SMTP, subject, body string) error { + if m.fail { + return errTest + } + m.subjects = append(m.subjects, subject) + m.bodies = append(m.bodies, body) + return nil +} + +type testErr struct{} + +func (testErr) Error() string { return "boom" } + +var errTest = testErr{} + +func newManager(t *testing.T) (*Manager, *mailSink) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + cfg, err := config.Load(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatal(err) + } + c := cfg.Get() + c.SMTP.Host, c.SMTP.To = "localhost", []string{"a@b"} + c.RemindHours = 2 + if err := cfg.Save(c); err != nil { + t.Fatal(err) + } + sink := &mailSink{} + return &Manager{Store: st, Config: cfg, Send: sink.send}, sink +} + +func TestReconcileLifecycle(t *testing.T) { + m, sink := newManager(t) + t0 := time.Date(2025, 9, 13, 10, 0, 0, 0, time.UTC) + ev := []Event{{Key: "zfs:tank:health", Level: LevelCrit, Title: "Pool tank is DEGRADED", Detail: "d"}} + + out, err := m.Reconcile(Input{Now: t0}, ev) + if err != nil { + t.Fatal(err) + } + if len(out.Opened) != 1 || !out.Mailed || len(sink.subjects) != 1 { + t.Fatalf("open: %+v mails=%v", out, sink.subjects) + } + if !strings.Contains(sink.subjects[0], "Pool tank is DEGRADED") { + t.Errorf("subject = %q", sink.subjects[0]) + } + + // same event again shortly after: nothing new, no mail + out, _ = m.Reconcile(Input{Now: t0.Add(time.Hour)}, ev) + if len(out.Opened) != 0 || out.Mailed || len(sink.subjects) != 1 { + t.Fatalf("no-change cycle mailed: %+v", out) + } + + // after RemindHours: reminder mail + out, _ = m.Reconcile(Input{Now: t0.Add(3 * time.Hour)}, ev) + if len(out.Reminded) != 1 || !out.Mailed || len(sink.subjects) != 2 { + t.Fatalf("reminder: %+v mails=%v", out, sink.subjects) + } + if !strings.Contains(sink.bodies[1], "STILL OPEN") { + t.Errorf("reminder body: %q", sink.bodies[1]) + } + + // frozen prefix: alert must survive a cycle without data + out, _ = m.Reconcile(Input{Now: t0.Add(4 * time.Hour), Frozen: []string{"zfs:"}}, nil) + if len(out.Resolved) != 0 { + t.Fatalf("frozen alert was resolved") + } + + // condition gone: resolved + mail + out, _ = m.Reconcile(Input{Now: t0.Add(5 * time.Hour)}, nil) + if len(out.Resolved) != 1 || !out.Mailed || !strings.Contains(sink.bodies[len(sink.bodies)-1], "RESOLVED") { + t.Fatalf("resolve: %+v", out) + } + openNow, _ := m.Store.OpenAlerts() + if len(openNow) != 0 { + t.Errorf("still open: %+v", openNow) + } +} + +func TestReconcileMailFailureKeepsUnnotified(t *testing.T) { + m, sink := newManager(t) + sink.fail = true + t0 := time.Now() + ev := []Event{{Key: "smart:A:health", Level: LevelCrit, Title: "x", Detail: "y"}} + out, _ := m.Reconcile(Input{Now: t0}, ev) + if out.Mailed || out.MailErr == nil { + t.Fatalf("expected mail error: %+v", out) + } + open, _ := m.Store.OpenAlerts() + if len(open) != 1 || open[0].LastNotifiedAt != nil { + t.Fatalf("alert should be open and un-notified: %+v", open) + } + // within the backoff window nothing is attempted + sink.fail = false + out, _ = m.Reconcile(Input{Now: t0.Add(time.Minute)}, ev) + if out.Mailed || out.MailErr != nil { + t.Fatalf("mail attempted inside backoff: %+v", out) + } + // after the backoff the un-notified alert is retried as new + out, _ = m.Reconcile(Input{Now: t0.Add(mailRetryAfter + time.Minute)}, ev) + if !out.Mailed || len(out.Opened) != 1 { + t.Fatalf("un-notified alert not retried: %+v", out) + } +} + +func TestReconcileAcknowledged(t *testing.T) { + m, sink := newManager(t) + t0 := time.Date(2025, 9, 13, 10, 0, 0, 0, time.UTC) + ev := []Event{{Key: "smart:A:attr", Level: LevelWarn, Title: "SMART attributes degraded", Detail: "187 Reported_Uncorrect raw=6"}} + out, _ := m.Reconcile(Input{Now: t0}, ev) + if len(out.Opened) != 1 || len(sink.subjects) != 1 { + t.Fatalf("open: %+v", out) + } + id := out.Opened[0].ID + if err := m.Store.AckAlert(id, t0.Add(time.Minute)); err != nil { + t.Fatal(err) + } + + // acknowledged + unchanged: no reminder even after RemindHours + out, _ = m.Reconcile(Input{Now: t0.Add(5 * time.Hour)}, ev) + if out.Mailed || len(out.Reminded) != 0 { + t.Fatalf("acked alert reminded: %+v", out) + } + if act, _ := m.Store.ActiveAlerts(); len(act) != 0 { + t.Fatalf("acked alert still active: %+v", act) + } + + // counters grew: re-raised as new, mailed, visible again + ev[0].Detail = "187 Reported_Uncorrect raw=9" + out, _ = m.Reconcile(Input{Now: t0.Add(6 * time.Hour)}, ev) + if len(out.Opened) != 1 || !out.Mailed || len(sink.subjects) != 2 { + t.Fatalf("re-raise: %+v mails=%v", out, sink.subjects) + } + act, _ := m.Store.ActiveAlerts() + if len(act) != 1 || act[0].Detail != "187 Reported_Uncorrect raw=9" || act[0].Acked() { + t.Fatalf("re-raised alert: %+v", act) + } + + // acknowledged again, then escalated warn -> crit: re-raised + _ = m.Store.AckAlert(id, t0.Add(7*time.Hour)) + ev[0].Level = LevelCrit + out, _ = m.Reconcile(Input{Now: t0.Add(8 * time.Hour)}, ev) + if len(out.Opened) != 1 || len(sink.subjects) != 3 { + t.Fatalf("escalation not re-raised: %+v", out) + } + + // acknowledged, then the problem disappears: resolved normally + _ = m.Store.AckAlert(id, t0.Add(9*time.Hour)) + out, _ = m.Reconcile(Input{Now: t0.Add(10 * time.Hour)}, nil) + if len(out.Resolved) != 1 || len(sink.subjects) != 4 { + t.Fatalf("resolve of acked: %+v", out) + } + if open, _ := m.Store.OpenAlerts(); len(open) != 0 { + t.Fatalf("still open: %+v", open) + } +} + +func TestStoreAlertLog(t *testing.T) { + m, _ := newManager(t) + st := m.Store + t0 := time.Now().Add(-30 * 24 * time.Hour) + old, _ := st.OpenAlert("k:old", LevelWarn, "old", "d", t0) + _ = st.ResolveAlert(old.ID, t0.Add(time.Hour)) + fresh, _ := st.OpenAlert("k:fresh", LevelWarn, "fresh", "d", time.Now().Add(-2*time.Hour)) + _ = st.ResolveAlert(fresh.ID, time.Now().Add(-time.Hour)) + cur, _ := st.OpenAlert("k:cur", LevelCrit, "cur", "d", time.Now()) + + recent, _ := st.RecentAlerts(100, time.Now().Add(-7*24*time.Hour)) + if len(recent) != 2 { + t.Fatalf("recent = %+v", recent) + } + if n, _ := st.CountResolvedBefore(time.Now().Add(-7 * 24 * time.Hour)); n != 1 { + t.Errorf("hidden count = %d", n) + } + all, _ := st.RecentAlerts(100, time.Time{}) + if len(all) != 3 { + t.Fatalf("all = %+v", all) + } + // open alerts cannot be deleted, resolved ones can + _ = st.DeleteAlert(cur.ID) + _ = st.DeleteAlert(old.ID) + all, _ = st.RecentAlerts(100, time.Time{}) + if len(all) != 2 { + t.Fatalf("after delete = %+v", all) + } + _ = st.PruneAlerts(time.Now().Add(-time.Minute)) + all, _ = st.RecentAlerts(100, time.Time{}) + if len(all) != 1 || all[0].ID != cur.ID { + t.Fatalf("after prune = %+v", all) + } +} diff --git a/internal/alert/mail.go b/internal/alert/mail.go new file mode 100644 index 0000000..0a9cafa --- /dev/null +++ b/internal/alert/mail.go @@ -0,0 +1,128 @@ +package alert + +import ( + "crypto/tls" + "errors" + "fmt" + "mime" + "net" + "net/smtp" + "strings" + "time" + + "nasmon/internal/config" +) + +// SendMail delivers a plain-text message using the configured SMTP account. +func SendMail(cfg config.SMTP, subject, body string) error { + if cfg.Host == "" { + return errors.New("SMTP host is not configured") + } + if len(cfg.To) == 0 { + return errors.New("no recipients configured") + } + from := cfg.From + if from == "" { + from = cfg.User + } + if from == "" { + return errors.New("sender address (from) is not configured") + } + addr := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port)) + dialer := net.Dialer{Timeout: 20 * time.Second} + + var c *smtp.Client + switch cfg.Security { + case "tls": + conn, err := tls.DialWithDialer(&dialer, "tcp", addr, &tls.Config{ServerName: cfg.Host}) + if err != nil { + return fmt.Errorf("tls connect: %w", err) + } + c, err = smtp.NewClient(conn, cfg.Host) + if err != nil { + conn.Close() + return err + } + default: + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + conn.SetDeadline(time.Now().Add(60 * time.Second)) + c, err = smtp.NewClient(conn, cfg.Host) + if err != nil { + conn.Close() + return err + } + if cfg.Security == "starttls" { + if ok, _ := c.Extension("STARTTLS"); !ok { + c.Close() + return errors.New("server does not support STARTTLS") + } + if err := c.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil { + c.Close() + return fmt.Errorf("starttls: %w", err) + } + } + } + defer c.Close() + + if cfg.User != "" { + ok, mechs := c.Extension("AUTH") + var auth smtp.Auth + switch { + case ok && strings.Contains(mechs, "PLAIN"): + auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host) + case ok && strings.Contains(mechs, "LOGIN"): + auth = loginAuth{cfg.User, cfg.Pass} + case ok && strings.Contains(mechs, "CRAM-MD5"): + auth = smtp.CRAMMD5Auth(cfg.User, cfg.Pass) + default: + auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host) + } + if err := c.Auth(auth); err != nil { + return fmt.Errorf("auth: %w", err) + } + } + + if err := c.Mail(from); err != nil { + return fmt.Errorf("MAIL FROM: %w", err) + } + for _, to := range cfg.To { + if err := c.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO %s: %w", to, err) + } + } + w, err := c.Data() + if err != nil { + return err + } + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s", + from, strings.Join(cfg.To, ", "), mime.QEncoding.Encode("utf-8", subject), + time.Now().Format(time.RFC1123Z), strings.ReplaceAll(body, "\n", "\r\n")) + if _, err := w.Write([]byte(msg)); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + return c.Quit() +} + +// loginAuth implements the legacy AUTH LOGIN mechanism (Yandex, Mail.ru, Exchange). +type loginAuth struct{ user, pass string } + +func (a loginAuth) Start(*smtp.ServerInfo) (string, []byte, error) { return "LOGIN", nil, nil } + +func (a loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + switch strings.ToLower(strings.TrimSpace(string(fromServer))) { + case "username:": + return []byte(a.user), nil + case "password:": + return []byte(a.pass), nil + } + return nil, fmt.Errorf("unexpected LOGIN challenge %q", fromServer) +} diff --git a/internal/alert/rules.go b/internal/alert/rules.go new file mode 100644 index 0000000..05092ce --- /dev/null +++ b/internal/alert/rules.go @@ -0,0 +1,212 @@ +// Package alert evaluates health rules, tracks open alerts and sends mail. +package alert + +import ( + "fmt" + "sort" + "strings" + "time" + + "nasmon/internal/collect" + "nasmon/internal/config" + "nasmon/internal/store" +) + +const ( + LevelWarn = "warn" + LevelCrit = "crit" +) + +// Event is one active problem. Key is stable across evaluations so the same +// condition maps to the same open alert: "::". +type Event struct { + Key string + Level string + Title string + Detail string +} + +// Input is everything the rules look at in one evaluation. +type Input struct { + Now time.Time + Th config.Thresholds + Smarts []collect.Smart // disks with usable SMART data (standby ones excluded) + Prev map[string]*collect.Smart // previous snapshot by serial, for growth rules + Disks []collect.Disk + Seen []store.SeenDisk + Pools []collect.Pool + PoolsOK bool + CPUTemp float64 + HasCPU bool + RootFS collect.FSUsage + RootOK bool + // Frozen lists key prefixes whose open alerts must be left untouched + // because no fresh data was available for them in this cycle. + Frozen []string +} + +// criticalATA are attributes whose non-zero raw value is a warning sign. +var criticalATA = map[int]string{ + 5: "Reallocated_Sector_Ct", + 187: "Reported_Uncorrect", + 188: "Command_Timeout", + 197: "Current_Pending_Sector", + 198: "Offline_Uncorrectable", +} + +func diskLabel(s collect.Smart) string { + return fmt.Sprintf("%s (%s, %s)", s.Device, s.Model, s.Serial) +} + +// Evaluate returns the list of currently active events. +func Evaluate(in Input) []Event { + var ev []Event + add := func(key, level, title, detail string) { + ev = append(ev, Event{Key: key, Level: level, Title: title, Detail: detail}) + } + + for _, s := range in.Smarts { + if s.Error != "" && !s.Supported { + continue + } + pfx := "smart:" + s.Serial + ":" + lbl := diskLabel(s) + if s.Supported && !s.Healthy { + add(pfx+"health", LevelCrit, "SMART health FAILED: "+lbl, "smartctl reports overall-health self-assessment: FAILED. Back up the data and replace the disk.") + } + var bad []string + level := LevelWarn + for _, a := range s.Attrs { + if _, ok := criticalATA[a.ID]; ok && a.Raw > 0 { + bad = append(bad, fmt.Sprintf("%d %s raw=%d", a.ID, a.Name, a.Raw)) + if a.ID != 187 && a.ID != 188 { // uncorrect/timeouts are a hint, bad sectors are damage + level = LevelCrit + } + } + if a.WhenFailed != "" { + bad = append(bad, fmt.Sprintf("%d %s when_failed=%s (value %d, thresh %d)", a.ID, a.Name, a.WhenFailed, a.Value, a.Thresh)) + level = LevelCrit + } + } + if len(bad) > 0 { + add(pfx+"attr", level, "SMART attributes degraded: "+lbl, strings.Join(bad, "\n")) + } + if cur, ok := s.Attr(199); ok { + if prev := in.Prev[s.Serial]; prev != nil { + if p, ok := prev.Attr(199); ok && cur.Raw > p.Raw { + add(pfx+"crc", LevelWarn, "UDMA CRC errors increasing: "+lbl, + fmt.Sprintf("199 UDMA_CRC_Error_Count %d -> %d. Usually a cable/connector problem.", p.Raw, cur.Raw)) + } + } + } + if n := s.NVMe; n != nil { + var msgs []string + if n.CriticalWarning != 0 { + msgs = append(msgs, fmt.Sprintf("critical_warning=0x%02x", n.CriticalWarning)) + } + if n.MediaErrors > 0 { + msgs = append(msgs, fmt.Sprintf("media_errors=%d", n.MediaErrors)) + } + if n.PercentageUsed >= in.Th.NVMeUsedPct { + msgs = append(msgs, fmt.Sprintf("percentage_used=%d%% (>= %d%%)", n.PercentageUsed, in.Th.NVMeUsedPct)) + } + if n.AvailableSpare < in.Th.NVMeSparePct { + msgs = append(msgs, fmt.Sprintf("available_spare=%d%% (< %d%%)", n.AvailableSpare, in.Th.NVMeSparePct)) + } + if len(msgs) > 0 { + add(pfx+"nvme", LevelCrit, "NVMe health: "+lbl, strings.Join(msgs, "\n")) + } + } + if s.HasTemp && in.Th.DiskTempC > 0 && s.TempC >= in.Th.DiskTempC { + add(pfx+"temp", LevelWarn, fmt.Sprintf("Disk temperature %d°C: %s", s.TempC, lbl), + fmt.Sprintf("Threshold is %d°C.", in.Th.DiskTempC)) + } + if t := s.LastTest; t != nil && !t.InProgress && !t.Passed { + add(pfx+"selftest", LevelCrit, "SMART self-test failed: "+lbl, + fmt.Sprintf("%s: %s (at %d power-on hours)", t.Type, t.Status, t.Hours)) + } + } + + // disks that were seen before but are gone now + present := map[string]bool{} + for _, d := range in.Disks { + present[d.Serial] = true + } + for _, d := range in.Seen { + if !present[d.Serial] { + add("disk:"+d.Serial+":missing", LevelCrit, fmt.Sprintf("Disk disappeared: %s (%s)", d.Model, d.Serial), + fmt.Sprintf("Last seen as %s at %s. If it was removed on purpose, forget it on the disk page.", d.Device, d.LastSeen.Format(time.RFC3339))) + } + } + + if in.PoolsOK { + for _, p := range in.Pools { + pfx := "zfs:" + p.Name + ":" + if p.Health != "ONLINE" { + detail := "State: " + p.Health + if p.Status != "" { + detail += "\nStatus: " + p.Status + } + if p.Action != "" { + detail += "\nAction: " + p.Action + } + add(pfx+"health", LevelCrit, fmt.Sprintf("Pool %s is %s", p.Name, p.Health), detail) + } + var errs []string + for _, v := range p.Vdevs { + if v.Read > 0 || v.Write > 0 || v.Cksum > 0 { + errs = append(errs, fmt.Sprintf("%s: read=%d write=%d cksum=%d", v.Name, v.Read, v.Write, v.Cksum)) + } + } + if p.Errors != "" && !strings.HasPrefix(p.Errors, "No known data errors") { + errs = append(errs, "errors: "+p.Errors) + } + if p.ScanErrs > 0 { + errs = append(errs, fmt.Sprintf("last scan finished with %d errors", p.ScanErrs)) + } + if len(errs) > 0 { + add(pfx+"errors", LevelCrit, "Pool "+p.Name+" has errors", strings.Join(errs, "\n")) + } + if in.Th.PoolCapacityPct > 0 && p.CapPct >= in.Th.PoolCapacityPct { + add(pfx+"capacity", LevelWarn, fmt.Sprintf("Pool %s is %d%% full", p.Name, p.CapPct), + fmt.Sprintf("Pool size %s. Threshold %d%%.", humanBytes(p.Size), in.Th.PoolCapacityPct)) + } + if in.Th.ScrubMaxAgeDays > 0 && !p.Scrubbing { + maxAge := time.Duration(in.Th.ScrubMaxAgeDays) * 24 * time.Hour + if p.LastScrub == nil { + add(pfx+"scrub", LevelWarn, "Pool "+p.Name+" was never scrubbed", "Run: zpool scrub "+p.Name) + } else if age := in.Now.Sub(*p.LastScrub); age > maxAge { + add(pfx+"scrub", LevelWarn, fmt.Sprintf("Pool %s: last scrub %d days ago", p.Name, int(age.Hours()/24)), + fmt.Sprintf("Last scrub %s. Threshold %d days.", p.LastScrub.Format("2006-01-02"), in.Th.ScrubMaxAgeDays)) + } + } + } + } + + if in.HasCPU && in.Th.CPUTempC > 0 && in.CPUTemp >= float64(in.Th.CPUTempC) { + add("sys:cpu:temp", LevelWarn, fmt.Sprintf("CPU temperature %.0f°C", in.CPUTemp), fmt.Sprintf("Threshold %d°C.", in.Th.CPUTempC)) + } + if in.RootOK && in.RootFS.Total > 0 && in.Th.RootFSFreePct > 0 { + pct := int(in.RootFS.Free * 100 / in.RootFS.Total) + if pct < in.Th.RootFSFreePct { + add("sys:rootfs:space", LevelWarn, fmt.Sprintf("Root filesystem %d%% free", pct), + fmt.Sprintf("Root filesystem size %s. Threshold %d%%.", humanBytes(in.RootFS.Total), in.Th.RootFSFreePct)) + } + } + + sort.Slice(ev, func(i, j int) bool { return ev[i].Key < ev[j].Key }) + return ev +} + +func humanBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/alert/state.go b/internal/alert/state.go new file mode 100644 index 0000000..d52c513 --- /dev/null +++ b/internal/alert/state.go @@ -0,0 +1,188 @@ +package alert + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "nasmon/internal/config" + "nasmon/internal/store" +) + +// Manager reconciles evaluated events with the alerts table and notifies. +type Manager struct { + Store *store.Store + Config *config.Store + Send func(cfg config.SMTP, subject, body string) error + + lastErr string // last mail error logged, to avoid repeating it every cycle + nextMailTry time.Time // backoff after a delivery failure +} + +const mailRetryAfter = 10 * time.Minute + +// Outcome describes what one reconciliation did. +type Outcome struct { + Opened []store.Alert + Resolved []store.Alert + Reminded []store.Alert + Mailed bool + MailErr error +} + +func escalated(old, cur string) bool { return old == LevelWarn && cur == LevelCrit } + +func frozen(key string, prefixes []string) bool { + for _, p := range prefixes { + if strings.HasPrefix(key, p) { + return true + } + } + return false +} + +// Reconcile opens/resolves alerts based on the active events and sends one +// email describing everything that changed in this cycle. +func (m *Manager) Reconcile(in Input, events []Event) (Outcome, error) { + var out Outcome + cfg := m.Config.Get() + now := in.Now + + open, err := m.Store.OpenAlerts() + if err != nil { + return out, err + } + openByKey := map[string]store.Alert{} + for _, a := range open { + openByKey[a.Key] = a + } + active := map[string]Event{} + for _, e := range events { + active[e.Key] = e + } + + // alerts re-raised in this cycle (acknowledged, but the problem changed) + reraised := map[int64]bool{} + for _, e := range events { + if a, ok := openByKey[e.Key]; ok { + changed := a.Detail != e.Detail || a.Level != e.Level + if changed { + _ = m.Store.UpdateAlertDetail(a.ID, e.Level, e.Detail) + } + // an acknowledged alert stays quiet until the problem gets worse: + // escalated level or different details (grown counters, new errors) + if a.Acked() && (a.Detail != e.Detail || escalated(a.Level, e.Level)) { + _ = m.Store.ReraiseAlert(a.ID) + a.AckedAt, a.LastNotifiedAt, a.Level, a.Detail = nil, nil, e.Level, e.Detail + openByKey[e.Key] = a + reraised[a.ID] = true + log.Printf("alert re-raised: %s — %s", a.Key, a.Title) + out.Opened = append(out.Opened, a) + } + continue + } + a, err := m.Store.OpenAlert(e.Key, e.Level, e.Title, e.Detail, now) + if err != nil { + return out, err + } + log.Printf("alert opened: %s — %s", a.Key, a.Title) + out.Opened = append(out.Opened, a) + } + + remind := time.Duration(cfg.RemindHours) * time.Hour + for _, a := range open { + if _, still := active[a.Key]; still { + if reraised[a.ID] { + continue + } + switch { + case a.Acked(): + // acknowledged: no reminders, no retries + case a.LastNotifiedAt == nil: + // never delivered (mail failed earlier): retry as new + out.Opened = append(out.Opened, a) + case remind > 0 && now.Sub(*a.LastNotifiedAt) >= remind: + out.Reminded = append(out.Reminded, a) + } + continue + } + if frozen(a.Key, in.Frozen) { + continue + } + if err := m.Store.ResolveAlert(a.ID, now); err != nil { + return out, err + } + log.Printf("alert resolved: %s — %s", a.Key, a.Title) + out.Resolved = append(out.Resolved, a) + } + + if !cfg.AlertsEnabled || m.Send == nil { + return out, nil + } + if len(out.Opened) == 0 && len(out.Reminded) == 0 && (len(out.Resolved) == 0 || !cfg.NotifyResolved) { + return out, nil + } + if now.Before(m.nextMailTry) { + return out, nil + } + subject, body := compose(cfg, out) + if err := m.Send(cfg.SMTP, subject, body); err != nil { + out.MailErr = err + m.nextMailTry = now.Add(mailRetryAfter) + if err.Error() != m.lastErr { + log.Printf("alert mail failed (retry in %s): %v", mailRetryAfter, err) + m.lastErr = err.Error() + } + return out, nil + } + m.lastErr, m.nextMailTry = "", time.Time{} + out.Mailed = true + var ids []int64 + for _, a := range append(append([]store.Alert{}, out.Opened...), out.Reminded...) { + ids = append(ids, a.ID) + } + _ = m.Store.MarkNotified(ids, now) + return out, nil +} + +func compose(cfg config.Settings, o Outcome) (subject, body string) { + host, _ := os.Hostname() + var parts []string + if n := len(o.Opened); n > 0 { + parts = append(parts, fmt.Sprintf("%d new", n)) + } + if n := len(o.Reminded); n > 0 { + parts = append(parts, fmt.Sprintf("%d still open", n)) + } + if n := len(o.Resolved); n > 0 && cfg.NotifyResolved { + parts = append(parts, fmt.Sprintf("%d resolved", n)) + } + subject = strings.TrimSpace(cfg.SMTP.SubjectPrefix + " " + strings.Join(parts, ", ") + " on " + host) + if len(o.Opened) == 1 && len(o.Reminded) == 0 && len(o.Resolved) == 0 { + subject = strings.TrimSpace(cfg.SMTP.SubjectPrefix + " " + o.Opened[0].Title) + } + + var b strings.Builder + fmt.Fprintf(&b, "nasmon on %s, %s\n\n", host, time.Now().Format("2006-01-02 15:04:05 MST")) + section := func(title string, list []store.Alert) { + if len(list) == 0 { + return + } + fmt.Fprintf(&b, "== %s ==\n\n", title) + for _, a := range list { + fmt.Fprintf(&b, "[%s] %s\n", strings.ToUpper(a.Level), a.Title) + for _, l := range strings.Split(a.Detail, "\n") { + fmt.Fprintf(&b, " %s\n", l) + } + fmt.Fprintf(&b, " since %s\n\n", a.OpenedAt.Format("2006-01-02 15:04")) + } + } + section("NEW", o.Opened) + section("STILL OPEN", o.Reminded) + if cfg.NotifyResolved { + section("RESOLVED", o.Resolved) + } + return subject, b.String() +} diff --git a/internal/collect/cpu.go b/internal/collect/cpu.go new file mode 100644 index 0000000..9ad9b45 --- /dev/null +++ b/internal/collect/cpu.go @@ -0,0 +1,75 @@ +// Package collect reads system state from /proc, /sys and CLI tools. +package collect + +import ( + "bufio" + "bytes" + "fmt" + "os" + "runtime" + "strconv" + "strings" +) + +type CPU struct { + UsagePct float64 `json:"usage_pct"` + Cores int `json:"cores"` + Load [3]float64 `json:"load"` +} + +type cpuTimes struct{ idle, total uint64 } + +// CPUSampler keeps the previous /proc/stat reading to compute usage deltas. +type CPUSampler struct { + prev cpuTimes + init bool +} + +func (s *CPUSampler) Sample() (CPU, error) { + b, err := os.ReadFile("/proc/stat") + if err != nil { + return CPU{}, err + } + cur, err := parseProcStat(b) + if err != nil { + return CPU{}, err + } + c := CPU{Cores: runtime.NumCPU()} + if s.init && cur.total > s.prev.total { + dt := float64(cur.total - s.prev.total) + di := float64(cur.idle - s.prev.idle) + c.UsagePct = 100 * (1 - di/dt) + } + s.prev, s.init = cur, true + if lb, err := os.ReadFile("/proc/loadavg"); err == nil { + f := strings.Fields(string(lb)) + for i := 0; i < 3 && i < len(f); i++ { + c.Load[i], _ = strconv.ParseFloat(f[i], 64) + } + } + return c, nil +} + +// parseProcStat returns idle and total jiffies from the aggregate "cpu" line. +func parseProcStat(b []byte) (cpuTimes, error) { + sc := bufio.NewScanner(bytes.NewReader(b)) + for sc.Scan() { + f := strings.Fields(sc.Text()) + if len(f) < 5 || f[0] != "cpu" { + continue + } + var t cpuTimes + for i, v := range f[1:] { + n, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return t, err + } + t.total += n + if i == 3 || i == 4 { // idle + iowait + t.idle += n + } + } + return t, nil + } + return cpuTimes{}, fmt.Errorf("no cpu line in /proc/stat") +} diff --git a/internal/collect/disks.go b/internal/collect/disks.go new file mode 100644 index 0000000..8b0d694 --- /dev/null +++ b/internal/collect/disks.go @@ -0,0 +1,92 @@ +package collect + +import ( + "encoding/json" + "strconv" + "strings" + "time" +) + +type Disk struct { + Name string `json:"name"` // sda + Path string `json:"path"` // /dev/sda + Model string `json:"model"` + Serial string `json:"serial"` + Size uint64 `json:"size"` + Rota bool `json:"rota"` + Tran string `json:"tran"` // sata, nvme, usb +} + +type lsblkOut struct { + Blockdevices []struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Model string `json:"model"` + Serial string `json:"serial"` + Size flexUint `json:"size"` + Rota flexBool `json:"rota"` + Tran string `json:"tran"` + } `json:"blockdevices"` +} + +// flexBool accepts true/false as well as the "1"/"0" strings older lsblk prints. +type flexBool bool + +func (b *flexBool) UnmarshalJSON(data []byte) error { + switch strings.Trim(string(data), `"`) { + case "true", "1": + *b = true + default: + *b = false + } + return nil +} + +// flexUint accepts a JSON number or a numeric string. +type flexUint uint64 + +func (u *flexUint) UnmarshalJSON(data []byte) error { + n, err := strconv.ParseUint(strings.Trim(string(data), `"`), 10, 64) + if err != nil { + *u = 0 + return nil + } + *u = flexUint(n) + return nil +} + +// ListDisks returns physical block devices (whole disks, no loop/zram/dm). +func ListDisks() ([]Disk, error) { + b, err := run(10*time.Second, "lsblk", "-J", "-b", "-d", "-o", "NAME,PATH,TYPE,MODEL,SERIAL,SIZE,ROTA,TRAN") + if err != nil { + return nil, err + } + return parseLsblk(b) +} + +func parseLsblk(b []byte) ([]Disk, error) { + var o lsblkOut + if err := json.Unmarshal(b, &o); err != nil { + return nil, err + } + var out []Disk + for _, d := range o.Blockdevices { + if d.Type != "disk" { + continue + } + if strings.HasPrefix(d.Name, "zram") || strings.HasPrefix(d.Name, "loop") || + strings.HasPrefix(d.Name, "dm-") || strings.HasPrefix(d.Name, "zd") || strings.HasPrefix(d.Name, "sr") { + continue + } + path := d.Path + if path == "" { + path = "/dev/" + d.Name + } + out = append(out, Disk{ + Name: d.Name, Path: path, Model: strings.TrimSpace(d.Model), + Serial: strings.TrimSpace(d.Serial), Size: uint64(d.Size), Rota: bool(d.Rota), Tran: d.Tran, + }) + } + return out, nil +} diff --git a/internal/collect/exec.go b/internal/collect/exec.go new file mode 100644 index 0000000..ccc2f9b --- /dev/null +++ b/internal/collect/exec.go @@ -0,0 +1,28 @@ +package collect + +import ( + "bytes" + "context" + "errors" + "os/exec" + "time" +) + +// run executes a command with a timeout and returns stdout even on non-zero +// exit (smartctl encodes state in its exit code and still prints JSON). +func run(timeout time.Duration, name string, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return out.Bytes(), ee + } + return out.Bytes(), errors.New(name + ": " + err.Error() + " " + errb.String()) + } + return out.Bytes(), nil +} diff --git a/internal/collect/fs.go b/internal/collect/fs.go new file mode 100644 index 0000000..96414ee --- /dev/null +++ b/internal/collect/fs.go @@ -0,0 +1,20 @@ +//go:build linux + +package collect + +import "syscall" + +type FSUsage struct { + Path string `json:"path"` + Total uint64 `json:"total"` + Free uint64 `json:"free"` +} + +func ReadFS(path string) (FSUsage, error) { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return FSUsage{}, err + } + bs := uint64(st.Bsize) + return FSUsage{Path: path, Total: st.Blocks * bs, Free: st.Bavail * bs}, nil +} diff --git a/internal/collect/fs_other.go b/internal/collect/fs_other.go new file mode 100644 index 0000000..9fe6a87 --- /dev/null +++ b/internal/collect/fs_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package collect + +import "errors" + +type FSUsage struct { + Path string `json:"path"` + Total uint64 `json:"total"` + Free uint64 `json:"free"` +} + +func ReadFS(path string) (FSUsage, error) { return FSUsage{}, errors.New("unsupported OS") } diff --git a/internal/collect/mem.go b/internal/collect/mem.go new file mode 100644 index 0000000..0f26869 --- /dev/null +++ b/internal/collect/mem.go @@ -0,0 +1,77 @@ +package collect + +import ( + "bufio" + "bytes" + "os" + "strconv" + "strings" +) + +type Memory struct { + Total uint64 `json:"total"` + Available uint64 `json:"available"` + Used uint64 `json:"used"` // Total - Available + SwapTotal uint64 `json:"swap_total"` + SwapUsed uint64 `json:"swap_used"` + ARCSize uint64 `json:"arc_size"` + ARCMax uint64 `json:"arc_max"` +} + +func ReadMemory() (Memory, error) { + b, err := os.ReadFile("/proc/meminfo") + if err != nil { + return Memory{}, err + } + m := parseMeminfo(b) + if ab, err := os.ReadFile("/proc/spl/kstat/zfs/arcstats"); err == nil { + m.ARCSize, m.ARCMax = parseArcstats(ab) + } + return m, nil +} + +func parseMeminfo(b []byte) Memory { + kv := map[string]uint64{} + sc := bufio.NewScanner(bytes.NewReader(b)) + for sc.Scan() { + f := strings.Fields(sc.Text()) + if len(f) < 2 { + continue + } + n, err := strconv.ParseUint(f[1], 10, 64) + if err != nil { + continue + } + kv[strings.TrimSuffix(f[0], ":")] = n * 1024 + } + m := Memory{ + Total: kv["MemTotal"], + Available: kv["MemAvailable"], + SwapTotal: kv["SwapTotal"], + } + m.Used = m.Total - m.Available + m.SwapUsed = m.SwapTotal - kv["SwapFree"] + return m +} + +// parseArcstats picks "size" and "c_max" from the kstat table (name type data). +func parseArcstats(b []byte) (size, max uint64) { + sc := bufio.NewScanner(bytes.NewReader(b)) + for sc.Scan() { + f := strings.Fields(sc.Text()) + if len(f) != 3 { + continue + } + n, err := strconv.ParseUint(f[2], 10, 64) + if err != nil { + continue + } + switch f[0] { + case "size": + size = n + case "c_max": + max = n + } + } + return +} diff --git a/internal/collect/proc_test.go b/internal/collect/proc_test.go new file mode 100644 index 0000000..1375ae0 --- /dev/null +++ b/internal/collect/proc_test.go @@ -0,0 +1,62 @@ +package collect + +import "testing" + +func TestParseProcStat(t *testing.T) { + in := "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 50 0 25 400 25 0 0 0 0 0\n" + c, err := parseProcStat([]byte(in)) + if err != nil { + t.Fatal(err) + } + if c.total != 1000 || c.idle != 850 { + t.Errorf("total=%d idle=%d", c.total, c.idle) + } + if _, err := parseProcStat([]byte("intr 1 2 3\n")); err == nil { + t.Errorf("expected error without cpu line") + } +} + +func TestParseMeminfo(t *testing.T) { + in := "MemTotal: 16000000 kB\nMemFree: 1000000 kB\nMemAvailable: 9000000 kB\nSwapTotal: 2000000 kB\nSwapFree: 1500000 kB\n" + m := parseMeminfo([]byte(in)) + if m.Total != 16000000*1024 || m.Available != 9000000*1024 || m.Used != 7000000*1024 { + t.Errorf("mem = %+v", m) + } + if m.SwapTotal != 2000000*1024 || m.SwapUsed != 500000*1024 { + t.Errorf("swap = %+v", m) + } +} + +func TestParseArcstats(t *testing.T) { + in := "13 1 0x01 123 1 2 3\nname type data\nhits 4 1000\nsize 4 4294967296\nc_max 4 8589934592\n" + size, max := parseArcstats([]byte(in)) + if size != 4294967296 || max != 8589934592 { + t.Errorf("size=%d max=%d", size, max) + } +} + +func TestCPUTemp(t *testing.T) { + temps := []Temp{ + {Chip: "nvme", Label: "Composite", C: 41}, + {Chip: "coretemp", Label: "Package id 0", C: 55}, + {Chip: "coretemp", Label: "Core 0", C: 52}, + {Chip: "acpitz", Label: "temp1", C: 27}, + } + c, ok := CPUTemp(temps) + if !ok || c != 55 { + t.Errorf("cpu temp = %v %v", c, ok) + } + if _, ok := CPUTemp([]Temp{{Chip: "nvme", Label: "Composite", C: 41}}); ok { + t.Errorf("nvme must not count as CPU") + } +} + +func TestParseSmbSessions(t *testing.T) { + in := `{"timestamp": "x", "version": "4.19", "smb_conf": "/etc/samba/smb.conf", "sessions": {"123": {"username": "a"}, "456": {"username": "b"}}}` + if n := parseSmbSessions([]byte(in)); n != 2 { + t.Errorf("sessions = %d", n) + } + if n := parseSmbSessions([]byte("garbage")); n != 0 { + t.Errorf("garbage = %d", n) + } +} diff --git a/internal/collect/services.go b/internal/collect/services.go new file mode 100644 index 0000000..8120555 --- /dev/null +++ b/internal/collect/services.go @@ -0,0 +1,58 @@ +package collect + +import ( + "encoding/json" + "os/exec" + "strings" + "time" +) + +// ShareServices is the read-only view of the file-sharing daemons. +type ShareServices struct { + SMBInstalled bool `json:"smb_installed"` + NFSInstalled bool `json:"nfs_installed"` + SMBActive bool `json:"smb_active"` + NFSActive bool `json:"nfs_active"` + SMBSessions int `json:"smb_sessions"` +} + +// ReadShareServices checks which sharing daemons exist and run. +func ReadShareServices() ShareServices { + var s ShareServices + if _, err := exec.LookPath("smbd"); err == nil { + s.SMBInstalled = true + s.SMBActive = unitActive("smbd") + if s.SMBActive { + s.SMBSessions = smbSessions() + } + } + if _, err := exec.LookPath("exportfs"); err == nil { + s.NFSInstalled = true + s.NFSActive = unitActive("nfs-server") + } + return s +} + +func unitActive(unit string) bool { + out, _ := run(5*time.Second, "systemctl", "is-active", unit) + return strings.TrimSpace(string(out)) == "active" +} + +// smbSessions counts connected SMB sessions via smbstatus --json (Samba ≥ 4.17). +func smbSessions() int { + out, err := run(10*time.Second, "smbstatus", "--json", "-b") + if err != nil && len(out) == 0 { + return 0 + } + return parseSmbSessions(out) +} + +func parseSmbSessions(b []byte) int { + var j struct { + Sessions map[string]json.RawMessage `json:"sessions"` + } + if json.Unmarshal(b, &j) != nil { + return 0 + } + return len(j.Sessions) +} diff --git a/internal/collect/smart.go b/internal/collect/smart.go new file mode 100644 index 0000000..ee217f1 --- /dev/null +++ b/internal/collect/smart.go @@ -0,0 +1,291 @@ +package collect + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type SmartAttr struct { + ID int `json:"id"` + Name string `json:"name"` + Value int `json:"value"` + Worst int `json:"worst"` + Thresh int `json:"thresh"` + Raw int64 `json:"raw"` + RawString string `json:"raw_string"` + WhenFailed string `json:"when_failed"` +} + +type NVMeHealth struct { + CriticalWarning int `json:"critical_warning"` + AvailableSpare int `json:"available_spare"` + SpareThreshold int `json:"spare_threshold"` + PercentageUsed int `json:"percentage_used"` + MediaErrors int64 `json:"media_errors"` + ErrLogEntries int64 `json:"err_log_entries"` + UnsafeShutdowns int64 `json:"unsafe_shutdowns"` +} + +type SelfTest struct { + Type string `json:"type"` + Status string `json:"status"` + Passed bool `json:"passed"` + Hours int64 `json:"hours"` + InProgress bool `json:"in_progress"` + Remaining int `json:"remaining_pct"` +} + +// Smart is the normalised view of one smartctl -j -a run. +type Smart struct { + Device string `json:"device"` + Protocol string `json:"protocol"` // ATA | NVMe | SCSI + Model string `json:"model"` + Serial string `json:"serial"` + Firmware string `json:"firmware"` + Capacity uint64 `json:"capacity"` + Standby bool `json:"standby"` // disk asleep, nothing was read + Supported bool `json:"supported"` // SMART available & enabled + Healthy bool `json:"healthy"` + TempC int `json:"temp_c"` + HasTemp bool `json:"has_temp"` + PowerOnHrs int64 `json:"power_on_hours"` + PowerCycles int64 `json:"power_cycles"` + Attrs []SmartAttr `json:"attrs,omitempty"` + NVMe *NVMeHealth `json:"nvme,omitempty"` + LastTest *SelfTest `json:"last_test,omitempty"` + Error string `json:"error,omitempty"` + CheckedAt time.Time `json:"checked_at"` +} + +// Attr returns the ATA attribute with the given id, if present. +func (s *Smart) Attr(id int) (SmartAttr, bool) { + for _, a := range s.Attrs { + if a.ID == id { + return a, true + } + } + return SmartAttr{}, false +} + +// ReadSmart runs smartctl for one device. skipStandby adds "-n standby" so a +// spun-down disk is not woken; then Standby=true and nothing else is filled. +func ReadSmart(dev string, skipStandby bool) Smart { + args := []string{"-j", "-a"} + if skipStandby { + args = append(args, "-n", "standby") + } + args = append(args, dev) + out, err := run(60*time.Second, "smartctl", args...) + s := parseSmart(out) + s.Device = dev + s.CheckedAt = time.Now() + if len(out) == 0 && err != nil { + s.Error = err.Error() + } + return s +} + +type smartMsg struct { + String string `json:"string"` + Severity string `json:"severity"` +} + +type smartJSON struct { + Smartctl struct { + ExitStatus int `json:"exit_status"` + Messages []smartMsg `json:"messages"` + } `json:"smartctl"` + Device struct { + Protocol string `json:"protocol"` + } `json:"device"` + ModelName string `json:"model_name"` + SerialNumber string `json:"serial_number"` + Firmware string `json:"firmware_version"` + UserCapacity struct { + Bytes uint64 `json:"bytes"` + } `json:"user_capacity"` + SmartSupport struct { + Available bool `json:"available"` + Enabled bool `json:"enabled"` + } `json:"smart_support"` + SmartStatus *struct { + Passed bool `json:"passed"` + } `json:"smart_status"` + Temperature struct { + Current *int `json:"current"` + } `json:"temperature"` + PowerOnTime struct { + Hours int64 `json:"hours"` + } `json:"power_on_time"` + PowerCycleCount int64 `json:"power_cycle_count"` + ATAAttrs struct { + Table []struct { + ID int `json:"id"` + Name string `json:"name"` + Value int `json:"value"` + Worst int `json:"worst"` + Thresh int `json:"thresh"` + WhenFailed string `json:"when_failed"` + Raw struct { + Value int64 `json:"value"` + String string `json:"string"` + } `json:"raw"` + } `json:"table"` + } `json:"ata_smart_attributes"` + ATASelfTestLog struct { + Standard struct { + Table []struct { + Type struct { + String string `json:"string"` + } `json:"type"` + Status struct { + String string `json:"string"` + Passed bool `json:"passed"` + } `json:"status"` + LifetimeHours int64 `json:"lifetime_hours"` + } `json:"table"` + } `json:"standard"` + } `json:"ata_smart_self_test_log"` + ATASmartData struct { + SelfTest struct { + Status struct { + String string `json:"string"` + RemainingPercent *int `json:"remaining_percent"` + } `json:"status"` + } `json:"self_test"` + } `json:"ata_smart_data"` + NVMeLog *struct { + CriticalWarning int `json:"critical_warning"` + AvailableSpare int `json:"available_spare"` + SpareThreshold int `json:"available_spare_threshold"` + PercentageUsed int `json:"percentage_used"` + MediaErrors int64 `json:"media_errors"` + NumErrLogEntries int64 `json:"num_err_log_entries"` + UnsafeShutdowns int64 `json:"unsafe_shutdowns"` + PowerOnHours int64 `json:"power_on_hours"` + PowerCycles int64 `json:"power_cycles"` + } `json:"nvme_smart_health_information_log"` + NVMeSelfTestLog struct { + Current struct { + Value int `json:"value"` + String string `json:"string"` + } `json:"current_self_test_operation"` + CurrentCompletion *int `json:"current_self_test_completion_percent"` + Table []struct { + Code struct { + String string `json:"string"` + } `json:"self_test_code"` + Result struct { + Value int `json:"value"` + String string `json:"string"` + } `json:"self_test_result"` + PowerOnHours int64 `json:"power_on_hours"` + } `json:"table"` + } `json:"nvme_self_test_log"` +} + +func parseSmart(b []byte) Smart { + var s Smart + var j smartJSON + if err := json.Unmarshal(b, &j); err != nil { + s.Error = fmt.Sprintf("parse smartctl output: %v", err) + return s + } + for _, m := range j.Smartctl.Messages { + if strings.Contains(strings.ToUpper(m.String), "STANDBY") { + s.Standby = true + return s + } + } + // bit 0: command line error, bit 1: device open failed + if j.Smartctl.ExitStatus&3 != 0 { + for _, m := range j.Smartctl.Messages { + if m.Severity == "error" { + s.Error = m.String + break + } + } + if s.Error == "" { + s.Error = fmt.Sprintf("smartctl exit status %d", j.Smartctl.ExitStatus) + } + } + s.Protocol = j.Device.Protocol + s.Model = j.ModelName + s.Serial = j.SerialNumber + s.Firmware = j.Firmware + s.Capacity = j.UserCapacity.Bytes + s.Supported = (j.SmartSupport.Available && j.SmartSupport.Enabled) || j.SmartStatus != nil + if j.SmartStatus != nil { + s.Healthy = j.SmartStatus.Passed + } + if j.Temperature.Current != nil { + s.TempC, s.HasTemp = *j.Temperature.Current, true + } + s.PowerOnHrs = j.PowerOnTime.Hours + s.PowerCycles = j.PowerCycleCount + for _, a := range j.ATAAttrs.Table { + raw := a.Raw.Value + if a.ID == 188 { + // Seagate packs three 16-bit counters into 188; the low word is the timeout count + raw &= 0xFFFF + } + s.Attrs = append(s.Attrs, SmartAttr{ + ID: a.ID, Name: a.Name, Value: a.Value, Worst: a.Worst, Thresh: a.Thresh, + Raw: raw, RawString: a.Raw.String, WhenFailed: a.WhenFailed, + }) + } + if j.NVMeLog != nil { + s.NVMe = &NVMeHealth{ + CriticalWarning: j.NVMeLog.CriticalWarning, + AvailableSpare: j.NVMeLog.AvailableSpare, + SpareThreshold: j.NVMeLog.SpareThreshold, + PercentageUsed: j.NVMeLog.PercentageUsed, + MediaErrors: j.NVMeLog.MediaErrors, + ErrLogEntries: j.NVMeLog.NumErrLogEntries, + UnsafeShutdowns: j.NVMeLog.UnsafeShutdowns, + } + if s.PowerOnHrs == 0 { + s.PowerOnHrs = j.NVMeLog.PowerOnHours + } + if s.PowerCycles == 0 { + s.PowerCycles = j.NVMeLog.PowerCycles + } + } + // self-test: an in-progress test wins, otherwise the newest log entry + if rp := j.ATASmartData.SelfTest.Status.RemainingPercent; rp != nil { + s.LastTest = &SelfTest{Status: j.ATASmartData.SelfTest.Status.String, InProgress: true, Remaining: *rp, Passed: true} + } else if t := j.ATASelfTestLog.Standard.Table; len(t) > 0 { + s.LastTest = &SelfTest{Type: t[0].Type.String, Status: t[0].Status.String, Passed: t[0].Status.Passed, Hours: t[0].LifetimeHours} + } else if j.NVMeSelfTestLog.Current.Value != 0 { + rem := 0 + if j.NVMeSelfTestLog.CurrentCompletion != nil { + rem = 100 - *j.NVMeSelfTestLog.CurrentCompletion + } + s.LastTest = &SelfTest{Status: j.NVMeSelfTestLog.Current.String, InProgress: true, Remaining: rem, Passed: true} + } else if t := j.NVMeSelfTestLog.Table; len(t) > 0 { + s.LastTest = &SelfTest{Type: t[0].Code.String, Status: t[0].Result.String, Passed: t[0].Result.Value == 0, Hours: t[0].PowerOnHours} + } + return s +} + +// StartSelfTest launches a short or long SMART self-test on the device. +func StartSelfTest(dev, kind string) error { + if kind != "short" && kind != "long" { + return fmt.Errorf("unknown self-test kind %q", kind) + } + out, err := run(30*time.Second, "smartctl", "-j", "-t", kind, dev) + if err != nil { + var j smartJSON + if json.Unmarshal(out, &j) == nil { + for _, m := range j.Smartctl.Messages { + if m.Severity == "error" { + return fmt.Errorf("smartctl -t %s %s: %s", kind, dev, m.String) + } + } + } + return fmt.Errorf("smartctl -t %s %s: %w", kind, dev, err) + } + return nil +} diff --git a/internal/collect/smart_test.go b/internal/collect/smart_test.go new file mode 100644 index 0000000..e72422e --- /dev/null +++ b/internal/collect/smart_test.go @@ -0,0 +1,165 @@ +package collect + +import "testing" + +const smartATA = `{ + "smartctl": {"version": [7, 4], "exit_status": 0, "messages": []}, + "device": {"name": "/dev/sda", "type": "sat", "protocol": "ATA"}, + "model_name": "WDC WD80EFZZ-68BTXN0", + "serial_number": "WD-AAA", + "firmware_version": "81.00A81", + "user_capacity": {"blocks": 15628053168, "bytes": 8001563222016}, + "rotation_rate": 5640, + "smart_support": {"available": true, "enabled": true}, + "smart_status": {"passed": true}, + "ata_smart_data": { + "self_test": {"status": {"value": 0, "string": "completed without error", "passed": true}} + }, + "ata_smart_attributes": {"table": [ + {"id": 5, "name": "Reallocated_Sector_Ct", "value": 200, "worst": 200, "thresh": 140, "when_failed": "", "raw": {"value": 0, "string": "0"}}, + {"id": 9, "name": "Power_On_Hours", "value": 90, "worst": 90, "thresh": 0, "when_failed": "", "raw": {"value": 7541, "string": "7541"}}, + {"id": 194, "name": "Temperature_Celsius", "value": 116, "worst": 100, "thresh": 0, "when_failed": "", "raw": {"value": 34, "string": "34"}}, + {"id": 197, "name": "Current_Pending_Sector", "value": 200, "worst": 200, "thresh": 0, "when_failed": "", "raw": {"value": 8, "string": "8"}}, + {"id": 199, "name": "UDMA_CRC_Error_Count", "value": 200, "worst": 200, "thresh": 0, "when_failed": "", "raw": {"value": 2, "string": "2"}}, + {"id": 188, "name": "Command_Timeout", "value": 100, "worst": 100, "thresh": 0, "when_failed": "", "raw": {"value": 196611, "string": "3 3 3"}} + ]}, + "power_on_time": {"hours": 7541}, + "power_cycle_count": 21, + "temperature": {"current": 34}, + "ata_smart_self_test_log": {"standard": {"revision": 1, "table": [ + {"type": {"value": 2, "string": "Extended offline"}, "status": {"value": 0, "string": "Completed without error", "passed": true}, "lifetime_hours": 7500}, + {"type": {"value": 1, "string": "Short offline"}, "status": {"value": 0, "string": "Completed without error", "passed": true}, "lifetime_hours": 7400} + ]}} +}` + +const smartNVMe = `{ + "smartctl": {"version": [7, 4], "exit_status": 0}, + "device": {"name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe"}, + "model_name": "Samsung SSD 980 500GB", + "serial_number": "S64ANL0T", + "firmware_version": "2B4QFXO7", + "user_capacity": {"blocks": 976773168, "bytes": 500107862016}, + "smart_support": {"available": true, "enabled": true}, + "smart_status": {"passed": true, "nvme": {"value": 0}}, + "nvme_smart_health_information_log": { + "critical_warning": 0, "temperature": 41, "available_spare": 100, "available_spare_threshold": 10, + "percentage_used": 3, "media_errors": 0, "num_err_log_entries": 12, "unsafe_shutdowns": 30, + "power_cycles": 300, "power_on_hours": 1234 + }, + "temperature": {"current": 41}, + "power_cycle_count": 300, + "power_on_time": {"hours": 1234}, + "nvme_self_test_log": {"current_self_test_operation": {"value": 0, "string": "No self-test in progress"}, + "table": [{"self_test_code": {"value": 1, "string": "Short"}, "self_test_result": {"value": 0, "string": "Completed without error"}, "power_on_hours": 1200}]} +}` + +const smartStandby = `{ + "smartctl": {"version": [7, 4], "exit_status": 2, + "messages": [{"string": "Device is in STANDBY mode, exit(2)", "severity": "information"}]}, + "device": {"name": "/dev/sdb", "type": "sat", "protocol": "ATA"} +}` + +const smartOpenFail = `{ + "smartctl": {"version": [7, 4], "exit_status": 2, + "messages": [{"string": "Smartctl open device: /dev/sdz failed: No such device", "severity": "error"}]} +}` + +const smartInProgress = `{ + "smartctl": {"version": [7, 4], "exit_status": 0}, + "device": {"protocol": "ATA"}, + "serial_number": "X", + "smart_support": {"available": true, "enabled": true}, + "smart_status": {"passed": true}, + "ata_smart_data": {"self_test": {"status": {"value": 249, "string": "in progress, 90% remaining", "remaining_percent": 90}}}, + "ata_smart_self_test_log": {"standard": {"table": [ + {"type": {"string": "Short offline"}, "status": {"string": "Completed: read failure", "passed": false}, "lifetime_hours": 10}]}} +}` + +func TestParseSmartATA(t *testing.T) { + s := parseSmart([]byte(smartATA)) + if s.Error != "" || s.Standby { + t.Fatalf("unexpected error/standby: %+v", s) + } + if s.Protocol != "ATA" || s.Serial != "WD-AAA" || !s.Supported || !s.Healthy { + t.Errorf("basic fields: %+v", s) + } + if !s.HasTemp || s.TempC != 34 || s.PowerOnHrs != 7541 || s.PowerCycles != 21 { + t.Errorf("temp/hours: %+v", s) + } + if a, ok := s.Attr(197); !ok || a.Raw != 8 { + t.Errorf("attr 197: %+v %v", a, ok) + } + if a, ok := s.Attr(188); !ok || a.Raw != 3 { + t.Errorf("attr 188 must be unpacked to the low word: %+v", a) + } + if s.LastTest == nil || s.LastTest.InProgress || !s.LastTest.Passed || s.LastTest.Type != "Extended offline" || s.LastTest.Hours != 7500 { + t.Errorf("last test: %+v", s.LastTest) + } +} + +func TestParseSmartNVMe(t *testing.T) { + s := parseSmart([]byte(smartNVMe)) + if s.NVMe == nil { + t.Fatalf("nvme log missing: %+v", s) + } + if s.NVMe.PercentageUsed != 3 || s.NVMe.AvailableSpare != 100 || s.NVMe.ErrLogEntries != 12 { + t.Errorf("nvme: %+v", s.NVMe) + } + if s.TempC != 41 || s.PowerOnHrs != 1234 || !s.Healthy { + t.Errorf("fields: %+v", s) + } + if s.LastTest == nil || s.LastTest.Type != "Short" || !s.LastTest.Passed { + t.Errorf("last test: %+v", s.LastTest) + } +} + +func TestParseSmartStandby(t *testing.T) { + s := parseSmart([]byte(smartStandby)) + if !s.Standby || s.Error != "" { + t.Errorf("standby not detected: %+v", s) + } +} + +func TestParseSmartOpenFail(t *testing.T) { + s := parseSmart([]byte(smartOpenFail)) + if s.Standby || s.Error == "" || s.Supported { + t.Errorf("open failure not reported: %+v", s) + } +} + +func TestParseSmartInProgress(t *testing.T) { + s := parseSmart([]byte(smartInProgress)) + if s.LastTest == nil || !s.LastTest.InProgress || s.LastTest.Remaining != 90 { + t.Errorf("in-progress test: %+v", s.LastTest) + } +} + +func TestParseSmartGarbage(t *testing.T) { + s := parseSmart([]byte("not json")) + if s.Error == "" { + t.Errorf("expected parse error") + } +} + +func TestParseLsblk(t *testing.T) { + in := `{"blockdevices": [ + {"name":"sda","path":"/dev/sda","type":"disk","model":"WDC WD80EFZZ-68BTXN0","serial":"WD-AAA","size":8001563222016,"rota":true,"tran":"sata"}, + {"name":"nvme0n1","path":"/dev/nvme0n1","type":"disk","model":"Samsung SSD 980 500GB","serial":"S64ANL0T","size":500107862016,"rota":false,"tran":"nvme"}, + {"name":"loop0","path":"/dev/loop0","type":"loop","model":null,"serial":null,"size":4096,"rota":false,"tran":null}, + {"name":"zd0","path":"/dev/zd0","type":"disk","model":null,"serial":null,"size":4096,"rota":false,"tran":null}, + {"name":"sr0","path":"/dev/sr0","type":"rom","model":"DVD","serial":null,"size":1024,"rota":true,"tran":"sata"} + ]}` + disks, err := parseLsblk([]byte(in)) + if err != nil { + t.Fatal(err) + } + if len(disks) != 2 || disks[0].Name != "sda" || !disks[0].Rota || disks[1].Tran != "nvme" || disks[1].Rota { + t.Errorf("disks = %+v", disks) + } + // older util-linux prints rota as a string + old := `{"blockdevices": [{"name":"sda","type":"disk","size":"1000","rota":"1","tran":"sata"}]}` + disks, err = parseLsblk([]byte(old)) + if err != nil || len(disks) != 1 || !disks[0].Rota || disks[0].Path != "/dev/sda" { + t.Errorf("old lsblk: %+v %v", disks, err) + } +} diff --git a/internal/collect/temps.go b/internal/collect/temps.go new file mode 100644 index 0000000..2164caf --- /dev/null +++ b/internal/collect/temps.go @@ -0,0 +1,89 @@ +package collect + +import ( + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +type Temp struct { + Chip string `json:"chip"` + Label string `json:"label"` + C float64 `json:"c"` +} + +// ReadTemps lists every hwmon temperature input; falls back to thermal zones. +func ReadTemps() []Temp { + temps := readHwmon("/sys/class/hwmon") + if len(temps) == 0 { + temps = readThermalZones("/sys/class/thermal") + } + sort.Slice(temps, func(i, j int) bool { + if temps[i].Chip != temps[j].Chip { + return temps[i].Chip < temps[j].Chip + } + return temps[i].Label < temps[j].Label + }) + return temps +} + +func readHwmon(root string) []Temp { + var out []Temp + chips, _ := filepath.Glob(filepath.Join(root, "hwmon*")) + for _, chip := range chips { + name := readTrim(filepath.Join(chip, "name")) + inputs, _ := filepath.Glob(filepath.Join(chip, "temp*_input")) + for _, in := range inputs { + v, err := strconv.ParseFloat(readTrim(in), 64) + if err != nil { + continue + } + base := strings.TrimSuffix(in, "_input") + label := readTrim(base + "_label") + if label == "" { + label = filepath.Base(base) + } + out = append(out, Temp{Chip: name, Label: label, C: v / 1000}) + } + } + return out +} + +func readThermalZones(root string) []Temp { + var out []Temp + zones, _ := filepath.Glob(filepath.Join(root, "thermal_zone*")) + for _, z := range zones { + v, err := strconv.ParseFloat(readTrim(filepath.Join(z, "temp")), 64) + if err != nil { + continue + } + out = append(out, Temp{Chip: "thermal", Label: readTrim(filepath.Join(z, "type")), C: v / 1000}) + } + return out +} + +// CPUTemp picks the most plausible CPU package temperature from the list. +func CPUTemp(temps []Temp) (float64, bool) { + best, found := 0.0, false + for _, t := range temps { + chip := strings.ToLower(t.Chip) + lbl := strings.ToLower(t.Label) + isCPU := chip == "coretemp" || chip == "k10temp" || chip == "zenpower" || + strings.Contains(lbl, "package") || strings.Contains(lbl, "tctl") || strings.Contains(lbl, "tdie") || + strings.Contains(lbl, "x86_pkg_temp") || strings.Contains(lbl, "cpu") + if isCPU && (!found || t.C > best) { + best, found = t.C, true + } + } + return best, found +} + +func readTrim(p string) string { + b, err := os.ReadFile(p) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} diff --git a/internal/collect/zfs.go b/internal/collect/zfs.go new file mode 100644 index 0000000..fa3a6ee --- /dev/null +++ b/internal/collect/zfs.go @@ -0,0 +1,226 @@ +package collect + +import ( + "bufio" + "bytes" + "regexp" + "strconv" + "strings" + "time" +) + +type Vdev struct { + Name string `json:"name"` + Depth int `json:"depth"` + State string `json:"state"` + Read int64 `json:"read"` + Write int64 `json:"write"` + Cksum int64 `json:"cksum"` + Note string `json:"note,omitempty"` +} + +type Pool struct { + Name string `json:"name"` + Size uint64 `json:"size"` + Alloc uint64 `json:"alloc"` + Free uint64 `json:"free"` + CapPct int `json:"cap_pct"` + Health string `json:"health"` + FragPct int `json:"frag_pct"` + Scan string `json:"scan"` + ScanErrs int64 `json:"scan_errors"` + LastScrub *time.Time `json:"last_scrub,omitempty"` + Scrubbing bool `json:"scrubbing"` + Errors string `json:"errors"` + Status string `json:"status,omitempty"` + Action string `json:"action,omitempty"` + Vdevs []Vdev `json:"vdevs"` +} + +type Dataset struct { + Name string `json:"name"` + Used uint64 `json:"used"` + Avail uint64 `json:"avail"` + Refer uint64 `json:"refer"` + Mountpoint string `json:"mountpoint"` +} + +// ReadPools combines zpool list and zpool status for every pool. +func ReadPools() ([]Pool, error) { + out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag") + if err != nil { + return nil, err + } + pools := parseZpoolList(out) + for i := range pools { + st, err := run(20*time.Second, "zpool", "status", pools[i].Name) + if err != nil && len(st) == 0 { + continue + } + parseZpoolStatus(st, &pools[i]) + } + return pools, nil +} + +func ReadDatasets() ([]Dataset, error) { + out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint") + if err != nil { + return nil, err + } + return parseZfsList(out), nil +} + +func parseZpoolList(b []byte) []Pool { + var pools []Pool + sc := bufio.NewScanner(bytes.NewReader(b)) + for sc.Scan() { + f := strings.Split(sc.Text(), "\t") + if len(f) < 7 { + continue + } + p := Pool{Name: f[0], Health: f[5]} + p.Size, _ = strconv.ParseUint(f[1], 10, 64) + p.Alloc, _ = strconv.ParseUint(f[2], 10, 64) + p.Free, _ = strconv.ParseUint(f[3], 10, 64) + p.CapPct = atoiPct(f[4]) + p.FragPct = atoiPct(f[6]) + if p.CapPct == 0 && p.Size > 0 { + p.CapPct = int(p.Alloc * 100 / p.Size) + } + pools = append(pools, p) + } + return pools +} + +func atoiPct(s string) int { + n, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(s), "%")) + return n +} + +var ( + reSection = regexp.MustCompile(`^ *(pool|state|status|action|see|scan|scrub|config|errors|remove|checkpoint|expand):(.*)$`) + reScanDate = regexp.MustCompile(`on (\w{3} \w{3}\s+\d+ \d+:\d+:\d+ \d{4})`) + reScanErrs = regexp.MustCompile(`with (\d+) errors`) +) + +// parseZpoolStatus fills scan/errors/status/vdev tree from the text output of +// "zpool status " (zfs 2.2 has no JSON output). +func parseZpoolStatus(b []byte, p *Pool) { + sc := bufio.NewScanner(bytes.NewReader(b)) + section := "" + var scanLines, statusLines, actionLines []string + inConfig, headerSeen := false, false + for sc.Scan() { + line := sc.Text() + trim := strings.TrimSpace(line) + if inConfig { + if trim == "" { + if headerSeen { + inConfig = false + } + continue + } + if !headerSeen { + if strings.HasPrefix(trim, "NAME") { + headerSeen = true + } + continue + } + p.Vdevs = append(p.Vdevs, parseVdevLine(line)) + continue + } + if m := reSection.FindStringSubmatch(line); m != nil { + key, rest, _ := strings.Cut(trim, ":") + key = strings.TrimSpace(key) + rest = strings.TrimSpace(rest) + section = key + switch key { + case "config": + inConfig, headerSeen = true, false + case "scan", "scrub": + scanLines = append(scanLines, rest) + case "status": + statusLines = append(statusLines, rest) + case "action": + actionLines = append(actionLines, rest) + case "errors": + p.Errors = rest + case "state": + if p.Health == "" { + p.Health = rest + } + } + continue + } + // continuation line of the current section + switch section { + case "scan", "scrub": + scanLines = append(scanLines, trim) + case "status": + statusLines = append(statusLines, trim) + case "action": + actionLines = append(actionLines, trim) + case "errors": + if trim != "" { + p.Errors += " " + trim + } + } + } + p.Scan = strings.TrimSpace(strings.Join(scanLines, " ")) + p.Status = strings.TrimSpace(strings.Join(statusLines, " ")) + p.Action = strings.TrimSpace(strings.Join(actionLines, " ")) + p.Scrubbing = strings.Contains(p.Scan, "scrub in progress") + if m := reScanErrs.FindStringSubmatch(p.Scan); m != nil { + p.ScanErrs, _ = strconv.ParseInt(m[1], 10, 64) + } + if strings.HasPrefix(p.Scan, "scrub repaired") || strings.HasPrefix(p.Scan, "scrub canceled") { + if m := reScanDate.FindStringSubmatch(p.Scan); m != nil { + if t, err := time.ParseInLocation("Mon Jan _2 15:04:05 2006", squashSpaces(m[1]), time.Local); err == nil { + p.LastScrub = &t + } + } + } +} + +func squashSpaces(s string) string { return strings.Join(strings.Fields(s), " ") } + +func parseVdevLine(line string) Vdev { + // zpool indents with a tab, then two spaces per level + body := strings.TrimLeft(line, "\t") + indent := len(body) - len(strings.TrimLeft(body, " ")) + f := strings.Fields(body) + v := Vdev{Depth: indent / 2} + if len(f) == 0 { + return v + } + v.Name = f[0] + if len(f) >= 5 { + v.State = f[1] + v.Read, _ = strconv.ParseInt(f[2], 10, 64) + v.Write, _ = strconv.ParseInt(f[3], 10, 64) + v.Cksum, _ = strconv.ParseInt(f[4], 10, 64) + if len(f) > 5 { + v.Note = strings.Join(f[5:], " ") + } + } else if len(f) > 1 { + v.State = f[1] + } + return v +} + +func parseZfsList(b []byte) []Dataset { + var out []Dataset + sc := bufio.NewScanner(bytes.NewReader(b)) + for sc.Scan() { + f := strings.Split(sc.Text(), "\t") + if len(f) < 5 { + continue + } + d := Dataset{Name: f[0], Mountpoint: f[4]} + d.Used, _ = strconv.ParseUint(f[1], 10, 64) + d.Avail, _ = strconv.ParseUint(f[2], 10, 64) + d.Refer, _ = strconv.ParseUint(f[3], 10, 64) + out = append(out, d) + } + return out +} diff --git a/internal/collect/zfs_test.go b/internal/collect/zfs_test.go new file mode 100644 index 0000000..db4af89 --- /dev/null +++ b/internal/collect/zfs_test.go @@ -0,0 +1,130 @@ +package collect + +import ( + "testing" + "time" +) + +const zpoolStatusOK = ` pool: nas_pool_1_8_gb + state: ONLINE + scan: scrub repaired 0B in 03:12:41 with 0 errors on Sun Sep 7 03:36:42 2025 +config: + + NAME STATE READ WRITE CKSUM + nas_pool_1_8_gb ONLINE 0 0 0 + mirror-0 ONLINE 0 0 0 + ata-WDC_WD80EFZZ-68BTXN0_WD-AAA ONLINE 0 0 0 + ata-WDC_WD80EFZZ-68BTXN0_WD-BBB ONLINE 0 0 0 + +errors: No known data errors +` + +const zpoolStatusDegraded = ` pool: tank + state: DEGRADED +status: One or more devices could not be opened. Sufficient replicas exist for + the pool to continue functioning in a degraded state. +action: Attach the missing device and online it using 'zpool online'. + see: https://openzfs.github.io/openzfs-docs/msg/ZFS-8000-2Q + scan: scrub in progress since Sat Sep 13 01:00:00 2025 + 1.20T / 7.10T scanned at 1.02G/s, 500G / 7.10T issued at 430M/s + 0B repaired, 6.88% done, 04:28:12 to go +config: + + NAME STATE READ WRITE CKSUM + tank DEGRADED 0 0 0 + mirror-0 DEGRADED 0 0 0 + ata-DISK_A ONLINE 0 0 3 + 1234567890123456789 UNAVAIL 0 0 0 was /dev/sdb1 + logs + nvme0n1p2 ONLINE 0 0 0 + +errors: Permanent errors have been detected in the following files: + + /tank/data/file.bin +` + +func TestParseZpoolStatusOK(t *testing.T) { + p := Pool{Name: "nas_pool_1_8_gb"} + parseZpoolStatus([]byte(zpoolStatusOK), &p) + if p.Health != "ONLINE" { + t.Errorf("health = %q", p.Health) + } + if p.Errors != "No known data errors" { + t.Errorf("errors = %q", p.Errors) + } + if p.Scrubbing || p.ScanErrs != 0 { + t.Errorf("scan flags wrong: %+v", p) + } + if p.LastScrub == nil { + t.Fatalf("last scrub not parsed from %q", p.Scan) + } + want := time.Date(2025, 9, 7, 3, 36, 42, 0, time.Local) + if !p.LastScrub.Equal(want) { + t.Errorf("last scrub = %v, want %v", p.LastScrub, want) + } + if len(p.Vdevs) != 4 { + t.Fatalf("vdevs = %d: %+v", len(p.Vdevs), p.Vdevs) + } + if p.Vdevs[0].Depth != 0 || p.Vdevs[1].Depth != 1 || p.Vdevs[2].Depth != 2 { + t.Errorf("depths wrong: %+v", p.Vdevs) + } + if p.Vdevs[1].Name != "mirror-0" || p.Vdevs[1].State != "ONLINE" { + t.Errorf("mirror vdev wrong: %+v", p.Vdevs[1]) + } +} + +func TestParseZpoolStatusDegraded(t *testing.T) { + p := Pool{Name: "tank"} + parseZpoolStatus([]byte(zpoolStatusDegraded), &p) + if p.Health != "DEGRADED" { + t.Errorf("health = %q", p.Health) + } + if !p.Scrubbing { + t.Errorf("scrub in progress not detected: %q", p.Scan) + } + if p.LastScrub != nil { + t.Errorf("last scrub must be nil while scrubbing") + } + if p.Status == "" || p.Action == "" { + t.Errorf("status/action not captured: %q / %q", p.Status, p.Action) + } + if p.Errors == "" || p.Errors == "No known data errors" { + t.Errorf("errors = %q", p.Errors) + } + var unavail, cksum, logs bool + for _, v := range p.Vdevs { + switch { + case v.State == "UNAVAIL" && v.Note == "was /dev/sdb1": + unavail = true + case v.Name == "ata-DISK_A" && v.Cksum == 3: + cksum = true + case v.Name == "logs" && v.State == "": + logs = true + } + } + if !unavail || !cksum || !logs { + t.Errorf("vdev parse: unavail=%v cksum=%v logs=%v %+v", unavail, cksum, logs, p.Vdevs) + } +} + +func TestParseZpoolList(t *testing.T) { + out := "nas_pool_1_8_gb\t7992048291840\t3500000000000\t4492048291840\t43\tONLINE\t7\ntmp_pool\t1000000000000\t900000000000\t100000000000\t90\tONLINE\t-\n" + pools := parseZpoolList([]byte(out)) + if len(pools) != 2 { + t.Fatalf("pools = %d", len(pools)) + } + if pools[0].CapPct != 43 || pools[0].FragPct != 7 || pools[0].Health != "ONLINE" { + t.Errorf("pool0 = %+v", pools[0]) + } + if pools[1].CapPct != 90 || pools[1].FragPct != 0 { + t.Errorf("pool1 = %+v", pools[1]) + } +} + +func TestParseZfsList(t *testing.T) { + out := "nas_pool_1_8_gb\t100\t200\t50\t/nas_pool_1_8_gb\nnas_pool_1_8_gb/appdata\t10\t200\t10\t/nas_pool_1_8_gb/appdata\n" + ds := parseZfsList([]byte(out)) + if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 { + t.Errorf("datasets = %+v", ds) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..78b3bae --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,160 @@ +// Package config holds nasmon settings, persisted as a JSON file that is +// editable both by hand and through the web UI. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +type SMTP struct { + Host string `json:"host"` + Port int `json:"port"` + Security string `json:"security"` // starttls | tls | none + User string `json:"user"` + Pass string `json:"pass"` + From string `json:"from"` + To []string `json:"to"` + SubjectPrefix string `json:"subject_prefix"` +} + +type Thresholds struct { + DiskTempC int `json:"disk_temp_c"` + CPUTempC int `json:"cpu_temp_c"` + PoolCapacityPct int `json:"pool_capacity_pct"` + ScrubMaxAgeDays int `json:"scrub_max_age_days"` + RootFSFreePct int `json:"rootfs_free_pct"` + NVMeUsedPct int `json:"nvme_used_pct"` + NVMeSparePct int `json:"nvme_spare_pct"` +} + +type SelfTests struct { + Enabled bool `json:"enabled"` + ShortDays int `json:"short_days"` + LongDays int `json:"long_days"` + Hour int `json:"hour"` // local hour of day when tests may be started +} + +type Settings struct { + SMTP SMTP `json:"smtp"` + AlertsEnabled bool `json:"alerts_enabled"` + NotifyResolved bool `json:"notify_resolved"` + RemindHours int `json:"remind_hours"` + SmartIntervalMin int `json:"smart_interval_min"` + SmartSkipStandby bool `json:"smart_skip_standby"` + Thresholds Thresholds `json:"thresholds"` + SelfTests SelfTests `json:"self_tests"` +} + +func Defaults() Settings { + return Settings{ + SMTP: SMTP{Port: 587, Security: "starttls", SubjectPrefix: "[nasmon]"}, + AlertsEnabled: true, + NotifyResolved: true, + RemindHours: 24, + SmartIntervalMin: 30, + SmartSkipStandby: true, + Thresholds: Thresholds{ + DiskTempC: 50, + CPUTempC: 80, + PoolCapacityPct: 85, + ScrubMaxAgeDays: 45, + RootFSFreePct: 10, + NVMeUsedPct: 90, + NVMeSparePct: 10, + }, + SelfTests: SelfTests{Enabled: false, ShortDays: 7, LongDays: 30, Hour: 3}, + } +} + +// Validate normalises and checks values coming from the web form. +func (s *Settings) Validate() error { + s.SMTP.Security = strings.ToLower(strings.TrimSpace(s.SMTP.Security)) + switch s.SMTP.Security { + case "starttls", "tls", "none": + default: + return fmt.Errorf("smtp security must be starttls, tls or none") + } + if s.SMTP.Port <= 0 || s.SMTP.Port > 65535 { + return errors.New("smtp port out of range") + } + var to []string + for _, a := range s.SMTP.To { + if a = strings.TrimSpace(a); a != "" { + to = append(to, a) + } + } + s.SMTP.To = to + if s.RemindHours < 0 { + return errors.New("remind hours must be >= 0") + } + if s.SmartIntervalMin < 1 { + return errors.New("SMART interval must be >= 1 minute") + } + if s.SelfTests.Hour < 0 || s.SelfTests.Hour > 23 { + return errors.New("self-test hour must be 0..23") + } + if s.SelfTests.ShortDays < 0 || s.SelfTests.LongDays < 0 { + return errors.New("self-test periods must be >= 0") + } + return nil +} + +// Store is a thread-safe holder for the current settings backed by a file. +type Store struct { + path string + mu sync.RWMutex + cur Settings +} + +func Load(path string) (*Store, error) { + st := &Store{path: path, cur: Defaults()} + b, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + return st, nil + case err != nil: + return nil, err + } + if err := json.Unmarshal(b, &st.cur); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return st, nil +} + +func (st *Store) Get() Settings { + st.mu.RLock() + defer st.mu.RUnlock() + s := st.cur + s.SMTP.To = append([]string(nil), s.SMTP.To...) + return s +} + +func (st *Store) Save(s Settings) error { + if err := s.Validate(); err != nil { + return err + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + st.mu.Lock() + defer st.mu.Unlock() + if err := os.MkdirAll(filepath.Dir(st.path), 0o755); err != nil { + return err + } + tmp := st.path + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + if err := os.Rename(tmp, st.path); err != nil { + return err + } + st.cur = s + return nil +} diff --git a/internal/history/ring.go b/internal/history/ring.go new file mode 100644 index 0000000..58c186f --- /dev/null +++ b/internal/history/ring.go @@ -0,0 +1,59 @@ +// Package history keeps a fixed-size in-memory ring of metric samples. +package history + +import ( + "sync" + "time" +) + +type Sample struct { + T int64 `json:"t"` // unix seconds + CPU float64 `json:"cpu"` + MemUsed uint64 `json:"mem_used"` + ARC uint64 `json:"arc"` + CPUTemp float64 `json:"cpu_temp"` +} + +type Ring struct { + mu sync.RWMutex + buf []Sample + head int + n int +} + +func New(size int) *Ring { return &Ring{buf: make([]Sample, size)} } + +func (r *Ring) Add(s Sample) { + r.mu.Lock() + defer r.mu.Unlock() + r.buf[r.head] = s + r.head = (r.head + 1) % len(r.buf) + if r.n < len(r.buf) { + r.n++ + } +} + +// Since returns samples newer than t in chronological order, thinned to at +// most maxPoints entries. +func (r *Ring) Since(t time.Time, maxPoints int) []Sample { + r.mu.RLock() + defer r.mu.RUnlock() + ts := t.Unix() + out := make([]Sample, 0, r.n) + start := (r.head - r.n + len(r.buf)) % len(r.buf) + for i := 0; i < r.n; i++ { + s := r.buf[(start+i)%len(r.buf)] + if s.T >= ts { + out = append(out, s) + } + } + if maxPoints > 0 && len(out) > maxPoints { + step := (len(out) + maxPoints - 1) / maxPoints + thin := out[:0:0] + for i := 0; i < len(out); i += step { + thin = append(thin, out[i]) + } + out = thin + } + return out +} diff --git a/internal/hostrun/hostrun.go b/internal/hostrun/hostrun.go new file mode 100644 index 0000000..3a12414 --- /dev/null +++ b/internal/hostrun/hostrun.go @@ -0,0 +1,75 @@ +// Package hostrun executes privileged commands on the host. nasmon runs in a +// sandboxed mount namespace (ProtectSystem=strict), so anything that must +// mount, write /etc or talk to other daemons goes through a transient systemd +// unit (systemd-run) and thereby runs in the host's namespace. +package hostrun + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// Runner executes a command with the given stdin and returns its combined +// output; err is non-nil on a non-zero exit or when it could not be started. +type Runner func(ctx context.Context, stdin string, name string, args ...string) (out string, err error) + +// Direct runs the command in-process (development, tests, non-systemd hosts). +func Direct(ctx context.Context, stdin string, name string, args ...string) (string, error) { + return capture(exec.CommandContext(ctx, name, args...), stdin) +} + +// Host runs the command as a transient systemd unit in the host's mount +// namespace. --pipe forwards stdin/stdout/stderr, --wait the exit status. +func Host(ctx context.Context, stdin string, name string, args ...string) (string, error) { + full := append([]string{"--wait", "--pipe", "--collect", "--quiet", "--", name}, args...) + return capture(exec.CommandContext(ctx, "systemd-run", full...), stdin) +} + +func capture(cmd *exec.Cmd, stdin string) (string, error) { + var buf bytes.Buffer + cmd.Stdout, cmd.Stderr = &buf, &buf + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + err := cmd.Run() + out := strings.TrimSpace(buf.String()) + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) && out != "" { + return out, errors.New(out) + } + return out, fmt.Errorf("%s: %w", cmd.Args[0], err) + } + return out, nil +} + +// Auto picks Host when systemd manages the system and systemd-run exists, +// Direct otherwise. +func Auto() Runner { + if _, err := os.Stat("/run/systemd/system"); err == nil { + if _, err := exec.LookPath("systemd-run"); err == nil { + return Host + } + } + return Direct +} + +// WriteFile atomically replaces path with content on the host (mode 0644, +// parent directory created). A previous version is kept as path.bak so the +// caller can Restore it if validation fails. +func WriteFile(ctx context.Context, run Runner, path, content string) error { + script := `set -e; umask 022; d=$(dirname "$1"); mkdir -p "$d"; [ -f "$1" ] && cp -f "$1" "$1.bak"; cat > "$1.tmp"; mv -f "$1.tmp" "$1"` + _, err := run(ctx, content, "sh", "-c", script, "sh", path) + return err +} + +// Restore puts path.bak back in place of path (no-op when there is no backup). +func Restore(ctx context.Context, run Runner, path string) error { + _, err := run(ctx, "", "sh", "-c", `[ -f "$1.bak" ] && mv -f "$1.bak" "$1" || true`, "sh", path) + return err +} diff --git a/internal/sched/sched.go b/internal/sched/sched.go new file mode 100644 index 0000000..1f4de26 --- /dev/null +++ b/internal/sched/sched.go @@ -0,0 +1,479 @@ +// Package sched runs the collection loops and holds the latest snapshot. +package sched + +import ( + "context" + "log" + "os" + "sort" + "sync" + "time" + + "nasmon/internal/alert" + "nasmon/internal/collect" + "nasmon/internal/config" + "nasmon/internal/history" + "nasmon/internal/store" +) + +const ( + metricsEvery = 10 * time.Second + storageEvery = 60 * time.Second + smartRetain = 365 * 24 * time.Hour + alertRetain = 90 * 24 * time.Hour + selfTestSlack = 6 * time.Hour + selfTestWin = 3 // hours after SelfTests.Hour during which tests may start +) + +type DiskView struct { + collect.Disk + Smart *collect.Smart `json:"smart,omitempty"` // latest snapshot with data + Standby bool `json:"standby"` + Error string `json:"error,omitempty"` +} + +type Status struct { + Host string `json:"host"` + Version string `json:"version"` + Uptime float64 `json:"uptime_s"` + UpdatedAt time.Time `json:"updated_at"` + CPU collect.CPU `json:"cpu"` + Mem collect.Memory `json:"mem"` + Temps []collect.Temp `json:"temps"` + CPUTemp float64 `json:"cpu_temp"` + HasCPUTemp bool `json:"has_cpu_temp"` + Disks []DiskView `json:"disks"` + DisksErr string `json:"disks_err,omitempty"` + Pools []collect.Pool `json:"pools"` + PoolsErr string `json:"pools_err,omitempty"` + Datasets []collect.Dataset `json:"datasets"` + RootFS collect.FSUsage `json:"rootfs"` + Alerts []store.Alert `json:"alerts"` + Services collect.ShareServices `json:"services"` + LastSmartCheck time.Time `json:"last_smart_check"` + NextSmartCheck time.Time `json:"next_smart_check"` + SmartRunning bool `json:"smart_running"` + LastMailErr string `json:"last_mail_err,omitempty"` +} + +type Scheduler struct { + cfg *config.Store + st *store.Store + ring *history.Ring + alerts *alert.Manager + version string + started time.Time + + cpu collect.CPUSampler + wake chan struct{} + runMu sync.Mutex // serialises SMART runs + evalMu sync.Mutex // serialises rule evaluation (storage loop vs SMART pass) + + mu sync.RWMutex + status Status + // latest SMART data per serial, kept across standby reads + smart map[string]*collect.Smart + // serials whose latest read was standby (temperature is stale) + asleep map[string]bool +} + +func New(cfg *config.Store, st *store.Store, ring *history.Ring, am *alert.Manager, version string) *Scheduler { + host, _ := os.Hostname() + return &Scheduler{ + cfg: cfg, st: st, ring: ring, alerts: am, version: version, + started: time.Now(), + wake: make(chan struct{}, 1), + smart: map[string]*collect.Smart{}, + asleep: map[string]bool{}, + status: Status{Host: host, Version: version}, + } +} + +func (s *Scheduler) Status() Status { + s.mu.RLock() + defer s.mu.RUnlock() + st := s.status + st.Uptime = time.Since(s.started).Seconds() + return st +} + +// SetStatus replaces the snapshot; intended for tests and tooling. +func (s *Scheduler) SetStatus(st Status) { + s.mu.Lock() + defer s.mu.Unlock() + st.Host, st.Version = s.status.Host, s.status.Version + s.status = st +} + +// Reload re-plans the next SMART pass after the interval was changed. +func (s *Scheduler) Reload() { + cfg := s.cfg.Get() + s.mu.Lock() + if !s.status.LastSmartCheck.IsZero() { + s.status.NextSmartCheck = s.status.LastSmartCheck.Add(time.Duration(cfg.SmartIntervalMin) * time.Minute) + } + s.mu.Unlock() + s.poke() +} + +// RunSmartNow triggers an immediate SMART pass (non-blocking). +func (s *Scheduler) RunSmartNow() { + s.mu.Lock() + s.status.NextSmartCheck = time.Now() + s.mu.Unlock() + s.poke() +} + +func (s *Scheduler) poke() { + select { + case s.wake <- struct{}{}: + default: + } +} + +func (s *Scheduler) Run(ctx context.Context) { + s.collectMetrics() + s.collectStorage() + go s.metricsLoop(ctx) + go s.storageLoop(ctx) + s.smartLoop(ctx) +} + +func (s *Scheduler) metricsLoop(ctx context.Context) { + t := time.NewTicker(metricsEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.collectMetrics() + } + } +} + +func (s *Scheduler) storageLoop(ctx context.Context) { + t := time.NewTicker(storageEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.collectStorage() + s.evaluate() + } + } +} + +func (s *Scheduler) smartLoop(ctx context.Context) { + // first pass shortly after start so the UI has data + s.mu.Lock() + s.status.NextSmartCheck = time.Now().Add(5 * time.Second) + s.mu.Unlock() + lastPrune := time.Time{} + for { + s.mu.RLock() + next := s.status.NextSmartCheck + s.mu.RUnlock() + wait := time.Until(next) + if wait < 0 { + wait = 0 + } + select { + case <-ctx.Done(): + return + case <-s.wake: + continue + case <-time.After(wait): + } + s.runSmart() + if time.Since(lastPrune) > 24*time.Hour { + if err := s.st.PruneSmart(time.Now().Add(-smartRetain)); err != nil { + log.Printf("prune smart history: %v", err) + } + if err := s.st.PruneAlerts(time.Now().Add(-alertRetain)); err != nil { + log.Printf("prune alert log: %v", err) + } + lastPrune = time.Now() + } + } +} + +func (s *Scheduler) collectMetrics() { + cpu, err := s.cpu.Sample() + if err != nil { + log.Printf("cpu: %v", err) + } + mem, err := collect.ReadMemory() + if err != nil { + log.Printf("mem: %v", err) + } + temps := collect.ReadTemps() + cpuTemp, hasCPU := collect.CPUTemp(temps) + + s.mu.Lock() + s.status.CPU, s.status.Mem, s.status.Temps = cpu, mem, temps + s.status.CPUTemp, s.status.HasCPUTemp = cpuTemp, hasCPU + s.status.UpdatedAt = time.Now() + s.mu.Unlock() + + s.ring.Add(history.Sample{T: time.Now().Unix(), CPU: cpu.UsagePct, MemUsed: mem.Used, ARC: mem.ARCSize, CPUTemp: cpuTemp}) +} + +// RefreshAlerts reloads the active alert list after the user acknowledged one. +func (s *Scheduler) RefreshAlerts() { + alerts, err := s.st.ActiveAlerts() + if err != nil { + return + } + s.mu.Lock() + s.status.Alerts = alerts + s.mu.Unlock() +} + +// RefreshStorage re-reads disks, pools and datasets right away (after a +// dataset was created or destroyed from the UI). +func (s *Scheduler) RefreshStorage() { s.collectStorage() } + +func (s *Scheduler) collectStorage() { + disks, derr := collect.ListDisks() + pools, perr := collect.ReadPools() + datasets, dserr := collect.ReadDatasets() + root, _ := collect.ReadFS("/") + alerts, _ := s.st.ActiveAlerts() + services := collect.ReadShareServices() + + s.mu.Lock() + defer s.mu.Unlock() + if derr != nil { + s.status.DisksErr = derr.Error() + } else { + s.status.DisksErr = "" + s.status.Disks = s.buildViews(disks) + } + if perr != nil { + s.status.PoolsErr = perr.Error() + } else { + s.status.PoolsErr = "" + s.status.Pools = pools + } + if dserr == nil { // keep the last good list if zfs list failed transiently + s.status.Datasets = datasets + } + s.status.RootFS = root + s.status.Alerts = alerts + s.status.Services = services +} + +// buildViews merges lsblk disks with cached SMART data; caller holds s.mu. +func (s *Scheduler) buildViews(disks []collect.Disk) []DiskView { + views := make([]DiskView, 0, len(disks)) + for _, d := range disks { + v := DiskView{Disk: d} + if sm := s.smart[d.Serial]; sm != nil { + v.Smart = sm + v.Standby = s.asleep[d.Serial] + if sm.Error != "" && !sm.Supported { + v.Error = sm.Error + } + } + for _, old := range s.status.Disks { + if old.Serial == d.Serial && v.Smart == nil { + v.Standby, v.Error = old.Standby, old.Error + } + } + views = append(views, v) + } + sort.Slice(views, func(i, j int) bool { return views[i].Name < views[j].Name }) + return views +} + +func (s *Scheduler) runSmart() { + s.runMu.Lock() + defer s.runMu.Unlock() + cfg := s.cfg.Get() + now := time.Now() + + s.mu.Lock() + s.status.SmartRunning = true + s.status.NextSmartCheck = now.Add(time.Duration(cfg.SmartIntervalMin) * time.Minute) + s.mu.Unlock() + defer func() { + s.mu.Lock() + s.status.SmartRunning = false + s.status.LastSmartCheck = time.Now() + s.mu.Unlock() + }() + + disks, err := collect.ListDisks() + if err != nil { + log.Printf("lsblk: %v", err) + return + } + for _, d := range disks { + sm := collect.ReadSmart(d.Path, cfg.SmartSkipStandby) + serial := d.Serial + if sm.Serial == "" { + sm.Serial = serial + } + if sm.Standby { + s.mu.Lock() + s.asleep[serial] = true + if s.smart[serial] == nil { + if prev, _ := s.st.LastSmart(serial); prev != nil { + s.smart[serial] = prev + } + } + s.mu.Unlock() + continue + } + if sm.Error != "" && !sm.Supported { + log.Printf("smartctl %s: %s", d.Path, sm.Error) + s.mu.Lock() + s.asleep[serial] = false + if s.smart[serial] == nil { + s.smart[serial] = &sm + } + s.mu.Unlock() + continue + } + if err := s.st.AddSmart(sm); err != nil { + log.Printf("store smart: %v", err) + } + _ = s.st.TouchDisk(serial, d.Model, d.Path, now) + s.mu.Lock() + s.asleep[serial] = false + s.smart[serial] = &sm + s.mu.Unlock() + s.maybeSelfTest(cfg.SelfTests, d, &sm, now) + } + + s.mu.Lock() + s.status.Disks = s.buildViews(disks) + s.mu.Unlock() + s.evaluate() +} + +func (s *Scheduler) maybeSelfTest(c config.SelfTests, d collect.Disk, sm *collect.Smart, now time.Time) { + if !c.Enabled || !sm.Supported || d.Serial == "" { + return + } + if sm.LastTest != nil && sm.LastTest.InProgress { + return + } + h := now.Hour() + if h < c.Hour || h >= c.Hour+selfTestWin { + return + } + for _, k := range []struct { + kind string + days int + }{{"long", c.LongDays}, {"short", c.ShortDays}} { + if k.days <= 0 { + continue + } + last, err := s.st.LastSelfTest(d.Serial, k.kind) + if err != nil { + continue + } + if now.Sub(last) < time.Duration(k.days)*24*time.Hour-selfTestSlack { + continue + } + if err := collect.StartSelfTest(d.Path, k.kind); err != nil { + log.Printf("self-test: %v", err) + return + } + log.Printf("started %s self-test on %s (%s)", k.kind, d.Path, d.Serial) + _ = s.st.SetSelfTest(d.Serial, k.kind, now) + return + } +} + +// LoadCachedSmart primes the in-memory SMART cache from the database at start. +func (s *Scheduler) LoadCachedSmart() { + seen, err := s.st.SeenDisks() + if err != nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for _, d := range seen { + if sm, _ := s.st.LastSmart(d.Serial); sm != nil { + s.smart[d.Serial] = sm + } + } +} + +// evaluate runs alert rules against the current snapshot. +func (s *Scheduler) evaluate() { + s.evalMu.Lock() + defer s.evalMu.Unlock() + cfg := s.cfg.Get() + now := time.Now() + + s.mu.RLock() + st := s.status + in := alert.Input{ + Now: now, Th: cfg.Thresholds, + Disks: nil, Pools: st.Pools, PoolsOK: st.PoolsErr == "", + CPUTemp: st.CPUTemp, HasCPU: st.HasCPUTemp, + RootFS: st.RootFS, RootOK: st.RootFS.Total > 0, + Prev: map[string]*collect.Smart{}, + } + for _, v := range st.Disks { + in.Disks = append(in.Disks, v.Disk) + if v.Smart == nil { + in.Frozen = append(in.Frozen, "smart:"+v.Serial+":") + continue + } + sm := *v.Smart + if v.Standby { + sm.HasTemp = false // stale temperature while asleep + } + in.Smarts = append(in.Smarts, sm) + } + s.mu.RUnlock() + + // previous snapshots for growth rules + for _, sm := range in.Smarts { + if prev := s.prevSnapshot(sm); prev != nil { + in.Prev[sm.Serial] = prev + } + } + if st.DisksErr != "" { + in.Frozen = append(in.Frozen, "disk:", "smart:") + } else { + in.Seen, _ = s.st.SeenDisks() + } + if !in.PoolsOK { + in.Frozen = append(in.Frozen, "zfs:") + } + + events := alert.Evaluate(in) + out, err := s.alerts.Reconcile(in, events) + if err != nil { + log.Printf("alerts: %v", err) + return + } + alerts, _ := s.st.ActiveAlerts() + s.mu.Lock() + s.status.Alerts = alerts + if out.MailErr != nil { + s.status.LastMailErr = out.MailErr.Error() + } else if out.Mailed { + s.status.LastMailErr = "" + } + s.mu.Unlock() +} + +// prevSnapshot returns the oldest snapshot from the last 24 hours, so a +// "value increased" alert stays open for a day after the growth was seen. +func (s *Scheduler) prevSnapshot(cur collect.Smart) *collect.Smart { + hist, err := s.st.SmartHistory(cur.Serial, cur.CheckedAt.Add(-24*time.Hour)) + if err != nil || len(hist) < 2 { + return nil + } + p := hist[0] + return &collect.Smart{Serial: cur.Serial, Attrs: []collect.SmartAttr{{ID: 199, Raw: p.CRC}}} +} diff --git a/internal/shares/apply.go b/internal/shares/apply.go new file mode 100644 index 0000000..0732be0 --- /dev/null +++ b/internal/shares/apply.go @@ -0,0 +1,203 @@ +package shares + +import ( + "context" + "errors" + "fmt" + "log" + "os/exec" + "os/user" + "strings" + "time" + + "nasmon/internal/hostrun" +) + +const ( + DefaultSMBConf = "/etc/samba/smb.conf" + DefaultExports = "/etc/exports.d/nasmon.exports" + // DefaultOwnerUID is the account SMB sessions and squashed NFS clients act + // as: the user that owns the data and runs the containers on this NAS. + DefaultOwnerUID = "1000" +) + +// Manager writes the generated config files on the host and reloads the daemons. +type Manager struct { + Run hostrun.Runner + LookPath func(string) (string, error) + Owner string // Unix user name for force user + UID, GID string + SMBConf string + Exports string + Timeout time.Duration +} + +func New(run hostrun.Runner) *Manager { + m := &Manager{Run: run, LookPath: exec.LookPath, UID: DefaultOwnerUID, GID: DefaultOwnerUID, + SMBConf: DefaultSMBConf, Exports: DefaultExports, Timeout: 90 * time.Second} + m.Owner = m.UID + if u, err := user.LookupId(m.UID); err == nil { + m.Owner, m.GID = u.Username, u.Gid + } + return m +} + +func (m *Manager) ctx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), m.Timeout) +} + +func (m *Manager) installed(bin string) bool { + _, err := m.LookPath(bin) + return err == nil +} + +func (m *Manager) SMBInstalled() bool { return m.installed("smbd") } +func (m *Manager) NFSInstalled() bool { return m.installed("exportfs") } + +// Apply regenerates both config files and reloads/enables/disables the +// services. SMB and NFS are applied independently; errors are joined. +func (m *Manager) Apply(c Config) error { + var errs []error + if m.SMBInstalled() { + if err := m.applySMB(c); err != nil { + errs = append(errs, fmt.Errorf("SMB: %w", err)) + } + } + if m.NFSInstalled() { + if err := m.applyNFS(c); err != nil { + errs = append(errs, fmt.Errorf("NFS: %w", err)) + } + } + return errors.Join(errs...) +} + +func (m *Manager) applySMB(c Config) error { + ctx, cancel := m.ctx() + defer cancel() + if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(c, m.Owner)); err != nil { + return fmt.Errorf("write %s: %w", m.SMBConf, err) + } + if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil { + _ = hostrun.Restore(ctx, m.Run, m.SMBConf) + return fmt.Errorf("testparm rejected the config, previous file restored: %s", firstLines(out, 4)) + } + active := c.SMB.Enabled && len(c.SMB.Shares) > 0 + if !active { + _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd") + if err != nil { + return fmt.Errorf("stop smbd: %w", err) + } + log.Printf("shares: SMB disabled (%d shares, enabled=%v)", len(c.SMB.Shares), c.SMB.Enabled) + return nil + } + if _, err := m.Run(ctx, "", "systemctl", "enable", "--now", "smbd", "nmbd"); err != nil { + return fmt.Errorf("start smbd: %w", err) + } + if out, err := m.Run(ctx, "", "smbcontrol", "all", "reload-config"); err != nil { + return fmt.Errorf("reload smbd: %s", firstLines(out, 2)) + } + log.Printf("shares: SMB config applied, %d shares", len(c.SMB.Shares)) + return nil +} + +func (m *Manager) applyNFS(c Config) error { + ctx, cancel := m.ctx() + defer cancel() + if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(c, m.UID, m.GID)); err != nil { + return fmt.Errorf("write %s: %w", m.Exports, err) + } + active := c.NFS.Enabled && len(c.NFS.Exports) > 0 + if !active { + if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil { + return fmt.Errorf("stop nfs-server: %w", err) + } + log.Printf("shares: NFS disabled (%d exports, enabled=%v)", len(c.NFS.Exports), c.NFS.Enabled) + return nil + } + if _, err := m.Run(ctx, "", "systemctl", "enable", "--now", "nfs-server"); err != nil { + return fmt.Errorf("start nfs-server: %w", err) + } + if out, err := m.Run(ctx, "", "exportfs", "-ra"); err != nil { + _ = hostrun.Restore(ctx, m.Run, m.Exports) + _, _ = m.Run(ctx, "", "exportfs", "-ra") + return fmt.Errorf("exportfs rejected the exports, previous file restored: %s", firstLines(out, 4)) + } + log.Printf("shares: NFS exports applied, %d exports", len(c.NFS.Exports)) + return nil +} + +func firstLines(s string, n int) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + if len(lines) > n { + lines = lines[:n] + } + return strings.Join(lines, " | ") +} + +// ---- SMB users + +// ListUsers returns the Samba accounts (pdbedit -L: "name:uid:full name"). +func (m *Manager) ListUsers() ([]string, error) { + if !m.SMBInstalled() { + return nil, nil + } + ctx, cancel := m.ctx() + defer cancel() + out, err := m.Run(ctx, "", "pdbedit", "-L") + if err != nil { + return nil, err + } + var users []string + for _, l := range strings.Split(out, "\n") { + name, _, ok := strings.Cut(strings.TrimSpace(l), ":") + if ok && name != "" { + users = append(users, name) + } + } + return users, nil +} + +// SetPassword creates the Samba account (and a login-less Unix account if +// needed) or changes the password of an existing one. +func (m *Manager) SetPassword(name, pass string) error { + if err := ValidateUserName(name); err != nil { + return err + } + if len(pass) < 4 || strings.ContainsAny(pass, "\n\r") { + return errors.New("password must be at least 4 characters") + } + ctx, cancel := m.ctx() + defer cancel() + if _, err := m.Run(ctx, "", "getent", "passwd", name); err != nil { + // Unix account is required by Samba; no shell, no home, data owner's group + if out, err := m.Run(ctx, "", "useradd", "-M", "-N", "-g", m.GID, "-s", "/usr/sbin/nologin", "-c", "nasmon SMB user", name); err != nil { + return fmt.Errorf("create system user: %s", firstLines(out, 2)) + } + log.Printf("shares: system user %s created for SMB", name) + } + if out, err := m.Run(ctx, pass+"\n"+pass+"\n", "smbpasswd", "-s", "-a", name); err != nil { + return fmt.Errorf("smbpasswd: %s", firstLines(out, 2)) + } + log.Printf("shares: SMB password set for %s", name) + return nil +} + +// DeleteUser removes the Samba account; the Unix account is removed too when +// it is one nasmon created (nologin shell). +func (m *Manager) DeleteUser(name string) error { + if err := ValidateUserName(name); err != nil { + return err + } + ctx, cancel := m.ctx() + defer cancel() + if out, err := m.Run(ctx, "", "smbpasswd", "-x", name); err != nil { + return fmt.Errorf("smbpasswd -x: %s", firstLines(out, 2)) + } + if ent, err := m.Run(ctx, "", "getent", "passwd", name); err == nil && strings.HasSuffix(strings.TrimSpace(ent), "/usr/sbin/nologin") && name != m.Owner { + if out, err := m.Run(ctx, "", "userdel", name); err != nil { + return fmt.Errorf("SMB account removed, but userdel failed: %s", firstLines(out, 2)) + } + } + log.Printf("shares: SMB user %s deleted", name) + return nil +} diff --git a/internal/shares/model.go b/internal/shares/model.go new file mode 100644 index 0000000..6160bfe --- /dev/null +++ b/internal/shares/model.go @@ -0,0 +1,179 @@ +// Package shares manages SMB (Samba) and NFS exports: the definitions live in +// /etc/nasmon/shares.json, from which nasmon generates /etc/samba/smb.conf and +// /etc/exports.d/nasmon.exports and reloads the daemons. +package shares + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" +) + +type SMBShare struct { + Name string `json:"name"` + Path string `json:"path"` + Comment string `json:"comment"` + ReadOnly bool `json:"read_only"` + Guest bool `json:"guest"` // anyone on the LAN, no password + Users []string `json:"users"` // SMB users allowed when Guest is false + Browseable bool `json:"browseable"` + HideSysFiles bool `json:"hide_sys_files"` // veto ._*, .DS_Store, Thumbs.db, desktop.ini +} + +type SMBConfig struct { + Enabled bool `json:"enabled"` + Workgroup string `json:"workgroup"` + ServerString string `json:"server_string"` + Shares []SMBShare `json:"shares"` +} + +type NFSExport struct { + Path string `json:"path"` + Clients []string `json:"clients"` // host, IP, CIDR or * + ReadOnly bool `json:"read_only"` + Squash string `json:"squash"` // root | all | none + Async bool `json:"async"` +} + +type NFSConfig struct { + Enabled bool `json:"enabled"` + Exports []NFSExport `json:"exports"` +} + +type Config struct { + SMB SMBConfig `json:"smb"` + NFS NFSConfig `json:"nfs"` +} + +func Defaults() Config { + return Config{ + SMB: SMBConfig{Enabled: true, Workgroup: "WORKGROUP", ServerString: "Home NAS"}, + NFS: NFSConfig{Enabled: true}, + } +} + +// FindSMB returns the index of the share with that name, or -1. +func (c *Config) FindSMB(name string) int { + for i, s := range c.SMB.Shares { + if s.Name == name { + return i + } + } + return -1 +} + +// FindNFS returns the index of the export for that path, or -1. +func (c *Config) FindNFS(path string) int { + for i, e := range c.NFS.Exports { + if e.Path == path { + return i + } + } + return -1 +} + +func (c *Config) sortShares() { + sort.Slice(c.SMB.Shares, func(i, j int) bool { return c.SMB.Shares[i].Name < c.SMB.Shares[j].Name }) + sort.Slice(c.NFS.Exports, func(i, j int) bool { return c.NFS.Exports[i].Path < c.NFS.Exports[j].Path }) +} + +// Validate checks the whole configuration (pure; no filesystem access). +func (c *Config) Validate() error { + if err := ValidateWorkgroup(c.SMB.Workgroup); err != nil { + return err + } + seen := map[string]bool{} + for _, s := range c.SMB.Shares { + if err := s.Validate(); err != nil { + return err + } + if seen[s.Name] { + return fmt.Errorf("duplicate share name %s", s.Name) + } + seen[s.Name] = true + } + paths := map[string]bool{} + for _, e := range c.NFS.Exports { + if err := e.Validate(); err != nil { + return err + } + if paths[e.Path] { + return fmt.Errorf("duplicate NFS export %s", e.Path) + } + paths[e.Path] = true + } + return nil +} + +// Store is a thread-safe holder for the share definitions backed by a file. +type Store struct { + path string + mu sync.RWMutex + cur Config +} + +func Load(path string) (*Store, error) { + st := &Store{path: path, cur: Defaults()} + b, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + return st, nil + case err != nil: + return nil, err + } + if err := json.Unmarshal(b, &st.cur); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + st.cur.sortShares() + return st, nil +} + +func (st *Store) Get() Config { + st.mu.RLock() + defer st.mu.RUnlock() + return st.cur.clone() +} + +func (c Config) clone() Config { + out := c + out.SMB.Shares = make([]SMBShare, len(c.SMB.Shares)) + for i, s := range c.SMB.Shares { + s.Users = append([]string(nil), s.Users...) + out.SMB.Shares[i] = s + } + out.NFS.Exports = make([]NFSExport, len(c.NFS.Exports)) + for i, e := range c.NFS.Exports { + e.Clients = append([]string(nil), e.Clients...) + out.NFS.Exports[i] = e + } + return out +} + +func (st *Store) Save(c Config) error { + if err := c.Validate(); err != nil { + return err + } + c.sortShares() + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + st.mu.Lock() + defer st.mu.Unlock() + if err := os.MkdirAll(filepath.Dir(st.path), 0o755); err != nil { + return err + } + tmp := st.path + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil { + return err + } + if err := os.Rename(tmp, st.path); err != nil { + return err + } + st.cur = c.clone() + return nil +} diff --git a/internal/shares/render.go b/internal/shares/render.go new file mode 100644 index 0000000..99b9f1d --- /dev/null +++ b/internal/shares/render.go @@ -0,0 +1,114 @@ +package shares + +import ( + "fmt" + "strings" +) + +// Header marks generated files so install scripts and humans can tell them apart. +const Header = "# Managed by nasmon — edits are overwritten. Change shares at http://:8090/shares" + +func yesno(b bool) string { + if b { + return "yes" + } + return "no" +} + +// RenderSMB produces the complete smb.conf. owner is the Unix account every +// SMB session acts as (force user), so files end up with the same owner the +// containers use. +func RenderSMB(c Config, owner string) string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n\n[global]\n", Header) + g := []string{ + "workgroup = " + c.SMB.Workgroup, + "server string = " + c.SMB.ServerString, + "server role = standalone server", + "security = user", + "map to guest = Bad User", + "guest account = nobody", + "server min protocol = SMB2", + "unix charset = UTF-8", + "logging = file", + "log file = /var/log/samba/log.%m", + "max log size = 1000", + "log level = 1", + "load printers = no", + "printing = bsd", + "printcap name = /dev/null", + "disable spoolss = yes", + "ea support = yes", + "vfs objects = fruit streams_xattr", // macOS clients: resource forks / Finder metadata as xattrs + "fruit:metadata = stream", + "fruit:model = MacSamba", + "dos filemode = yes", + } + for _, l := range g { + fmt.Fprintf(&b, " %s\n", l) + } + for _, s := range c.SMB.Shares { + fmt.Fprintf(&b, "\n[%s]\n", s.Name) + lines := []string{ + "path = " + s.Path, + } + if s.Comment != "" { + lines = append(lines, "comment = "+s.Comment) + } + lines = append(lines, + "browseable = "+yesno(s.Browseable), + "read only = "+yesno(s.ReadOnly), + "guest ok = "+yesno(s.Guest), + ) + if !s.Guest { + lines = append(lines, "valid users = "+strings.Join(s.Users, " ")) + } + lines = append(lines, + "force user = "+owner, + "force group = "+owner, + "create mask = 0664", + "directory mask = 0775", + ) + if s.HideSysFiles { + lines = append(lines, "veto files = /._*/.DS_Store/Thumbs.db/desktop.ini/", "delete veto files = yes") + } + for _, l := range lines { + fmt.Fprintf(&b, " %s\n", l) + } + } + return b.String() +} + +// RenderExports produces the exports(5) file. uid/gid are the ids squashed +// clients are mapped to. +func RenderExports(c Config, uid, gid string) string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n", Header) + for _, e := range c.NFS.Exports { + opts := []string{"rw"} + if e.ReadOnly { + opts = []string{"ro"} + } + if e.Async { + opts = append(opts, "async") + } else { + opts = append(opts, "sync") + } + switch e.Squash { + case "all": + opts = append(opts, "all_squash", "anonuid="+uid, "anongid="+gid) + case "none": + opts = append(opts, "no_root_squash") + default: + opts = append(opts, "root_squash", "anonuid="+uid, "anongid="+gid) + } + // crossmnt: child ZFS datasets are separate mounts and must be reachable + opts = append(opts, "crossmnt", "no_subtree_check") + var parts []string + for _, cl := range e.Clients { + parts = append(parts, cl+"("+strings.Join(opts, ",")+")") + } + fmt.Fprintf(&b, "%q %s\n", e.Path, strings.Join(parts, " ")) + } + return b.String() +} diff --git a/internal/shares/shares_test.go b/internal/shares/shares_test.go new file mode 100644 index 0000000..0933002 --- /dev/null +++ b/internal/shares/shares_test.go @@ -0,0 +1,338 @@ +package shares + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" +) + +func sample() Config { + c := Defaults() + c.SMB.Shares = []SMBShare{ + {Name: "documents", Path: "/nas_pool_1_8_gb/documents", Comment: "Docs", Guest: true, Browseable: true, HideSysFiles: true}, + {Name: "private", Path: "/nas_pool_4_gb/backups", ReadOnly: true, Users: []string{"alice", "bob"}, Browseable: false}, + } + c.NFS.Exports = []NFSExport{ + {Path: "/nas_pool_1_8_gb/documents", Clients: []string{"192.168.101.0/24", "pc.lan"}, Squash: "root"}, + {Path: "/tmp_pool/downloads", Clients: []string{"*"}, ReadOnly: true, Squash: "all", Async: true}, + } + return c +} + +func TestRenderSMB(t *testing.T) { + out := RenderSMB(sample(), "ansible") + for _, want := range []string{ + "[global]", "workgroup = WORKGROUP", "map to guest = Bad User", "server min protocol = SMB2", + "[documents]", "path = /nas_pool_1_8_gb/documents", "comment = Docs", "guest ok = yes", "read only = no", "browseable = yes", + "veto files = /._*/.DS_Store/Thumbs.db/desktop.ini/", + "[private]", "guest ok = no", "valid users = alice bob", "read only = yes", "browseable = no", + "force user = ansible", "force group = ansible", "create mask = 0664", + } { + if !strings.Contains(out, want) { + t.Errorf("smb.conf missing %q\n%s", want, out) + } + } + if strings.Count(out, "valid users") != 1 { + t.Errorf("guest share must not list valid users") + } + if strings.Count(out, "\n veto files = ") != 1 { + t.Errorf("veto files only where requested") + } +} + +func TestRenderExports(t *testing.T) { + out := RenderExports(sample(), "1000", "1000") + want := []string{ + `"/nas_pool_1_8_gb/documents" 192.168.101.0/24(rw,sync,root_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check) pc.lan(rw,sync,root_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check)`, + `"/tmp_pool/downloads" *(ro,async,all_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check)`, + } + for _, w := range want { + if !strings.Contains(out, w) { + t.Errorf("exports missing %q\n%s", w, out) + } + } + none := Config{NFS: NFSConfig{Exports: []NFSExport{{Path: "/x", Clients: []string{"h"}, Squash: "none"}}}} + if !strings.Contains(RenderExports(none, "1", "1"), "no_root_squash") || strings.Contains(RenderExports(none, "1", "1"), "anonuid") { + t.Errorf("squash=none rendering wrong") + } +} + +func TestValidation(t *testing.T) { + for _, n := range []string{"docs", "My.Share-1", "a"} { + if err := ValidateShareName(n); err != nil { + t.Errorf("%q rejected: %v", n, err) + } + } + for _, n := range []string{"", "global", "Homes", "print$", "-x", "a b", "a/b", strings.Repeat("x", 81), "a[b]"} { + if ValidateShareName(n) == nil { + t.Errorf("%q accepted", n) + } + } + for _, p := range []string{"/nas/x", "/tmp_pool"} { + if err := ValidatePath(p); err != nil { + t.Errorf("%q rejected: %v", p, err) + } + } + for _, p := range []string{"", "/", "nas/x", "/nas/../etc", "/nas/", "/nas/x\"y", "/nas/x\n"} { + if ValidatePath(p) == nil { + t.Errorf("%q accepted", p) + } + } + if !UnderRoots("/nas_pool/docs/sub", []string{"/tmp_pool", "/nas_pool"}) || UnderRoots("/nas_pool2", []string{"/nas_pool"}) || UnderRoots("/etc", []string{"/"}) { + t.Errorf("UnderRoots") + } + for _, c := range []string{"*", "192.168.1.5", "192.168.1.0/24", "pc.lan", "*.lan", "fe80::1"} { + if err := ValidateClient(c); err != nil { + t.Errorf("%q rejected: %v", c, err) + } + } + for _, c := range []string{"", "192.168.1.0/33", "a b", "(rw)", "host,other"} { + if ValidateClient(c) == nil { + t.Errorf("%q accepted", c) + } + } + for _, u := range []string{"alice", "bob_2", "x-y"} { + if err := ValidateUserName(u); err != nil { + t.Errorf("%q rejected: %v", u, err) + } + } + for _, u := range []string{"", "Alice", "root", "nobody", "1abc", "a b"} { + if ValidateUserName(u) == nil { + t.Errorf("%q accepted", u) + } + } + c := sample() + if err := c.Validate(); err != nil { + t.Fatalf("sample invalid: %v", err) + } + c.SMB.Shares = append(c.SMB.Shares, SMBShare{Name: "documents", Path: "/x"}) + if c.Validate() == nil { + t.Errorf("duplicate share accepted") + } + c = sample() + c.SMB.Shares[1].Users = nil + if c.Validate() == nil { + t.Errorf("non-guest share without users accepted") + } + c = sample() + c.NFS.Exports[0].Squash = "maybe" + if c.Validate() == nil { + t.Errorf("bad squash accepted") + } + if got := SplitList(" a, b;c\n d "); strings.Join(got, "|") != "a|b|c|d" { + t.Errorf("SplitList = %v", got) + } +} + +func TestStoreRoundTrip(t *testing.T) { + p := filepath.Join(t.TempDir(), "shares.json") + st, err := Load(p) + if err != nil { + t.Fatal(err) + } + if err := st.Save(sample()); err != nil { + t.Fatal(err) + } + st2, err := Load(p) + if err != nil { + t.Fatal(err) + } + c := st2.Get() + if len(c.SMB.Shares) != 2 || len(c.NFS.Exports) != 2 || c.FindSMB("private") != 1 || c.FindNFS("/tmp_pool/downloads") != 1 { + t.Errorf("round trip: %+v", c) + } + // Get returns a copy + c.SMB.Shares[0].Name = "changed" + if st2.Get().SMB.Shares[0].Name == "changed" { + t.Errorf("Get leaked internal state") + } +} + +// fake records host commands; stdin is captured for writes. +type fake struct { + calls []string + stdin map[string]string + fail map[string]string + reply map[string]string + lookup map[string]bool +} + +func newFake() *fake { + return &fake{stdin: map[string]string{}, fail: map[string]string{}, reply: map[string]string{}, lookup: map[string]bool{"smbd": true, "exportfs": true}} +} + +func (f *fake) run(_ context.Context, stdin, name string, args ...string) (string, error) { + c := name + " " + strings.Join(args, " ") + f.calls = append(f.calls, c) + if stdin != "" { + f.stdin[c] = stdin + } + for k, v := range f.fail { + if strings.HasPrefix(c, k) { + return v, errors.New(v) + } + } + for k, v := range f.reply { + if strings.HasPrefix(c, k) { + return v, nil + } + } + return "", nil +} + +func (f *fake) lookPath(bin string) (string, error) { + if f.lookup[bin] { + return "/usr/sbin/" + bin, nil + } + return "", errors.New("not found") +} + +func (f *fake) has(prefix string) bool { + for _, c := range f.calls { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +func (f *fake) index(prefix string) int { + for i, c := range f.calls { + if strings.HasPrefix(c, prefix) { + return i + } + } + return -1 +} + +func newManager(f *fake) *Manager { + m := New(f.run) + m.LookPath = f.lookPath + m.Owner, m.UID, m.GID = "ansible", "1000", "1000" + return m +} + +func TestApplyOrder(t *testing.T) { + f := newFake() + m := newManager(f) + if err := m.Apply(sample()); err != nil { + t.Fatalf("apply: %v\n%v", err, f.calls) + } + // smb.conf written through sh with the rendered content on stdin + wi := f.index("sh -c") + if wi < 0 || !strings.Contains(f.calls[wi], DefaultSMBConf) { + t.Fatalf("smb.conf not written: %v", f.calls) + } + if !strings.Contains(f.stdin[f.calls[wi]], "[documents]") { + t.Errorf("stdin lacks rendered config") + } + ti, si, ri := f.index("testparm -s"), f.index("systemctl enable --now smbd nmbd"), f.index("smbcontrol all reload-config") + if !(wi < ti && ti < si && si < ri) { + t.Errorf("SMB order wrong: %v", f.calls) + } + ni, ei := f.index("systemctl enable --now nfs-server"), f.index("exportfs -ra") + if ni < 0 || ei < ni { + t.Errorf("NFS order wrong: %v", f.calls) + } + // exports written + found := false + for c, in := range f.stdin { + if strings.Contains(c, DefaultExports) && strings.Contains(in, "crossmnt") { + found = true + } + } + if !found { + t.Errorf("exports not written: %v", f.stdin) + } +} + +func TestApplyTestparmFailureRestores(t *testing.T) { + f := newFake() + f.fail["testparm"] = "Error loading services" + m := newManager(f) + err := m.Apply(sample()) + if err == nil || !strings.Contains(err.Error(), "Error loading services") { + t.Fatalf("expected testparm error, got %v", err) + } + if f.has("smbcontrol") || f.has("systemctl enable --now smbd") { + t.Errorf("smbd touched after failed testparm: %v", f.calls) + } + restored := false + for _, c := range f.calls { + if strings.HasPrefix(c, "sh -c") && strings.Contains(c, ".bak") && strings.Contains(c, "mv -f") && strings.Contains(c, DefaultSMBConf) && !strings.Contains(c, "cat >") { + restored = true + } + } + if !restored { + t.Errorf("backup not restored: %v", f.calls) + } + // NFS is still applied independently + if !f.has("exportfs -ra") { + t.Errorf("NFS skipped after SMB failure") + } +} + +func TestApplyDisablesWhenEmpty(t *testing.T) { + f := newFake() + m := newManager(f) + c := Defaults() // no shares, no exports + if err := m.Apply(c); err != nil { + t.Fatal(err) + } + if !f.has("systemctl disable --now smbd nmbd") || !f.has("systemctl disable --now nfs-server") { + t.Errorf("services not disabled: %v", f.calls) + } + if f.has("smbcontrol") || f.has("exportfs") { + t.Errorf("reload attempted with nothing to serve: %v", f.calls) + } +} + +func TestApplyNotInstalled(t *testing.T) { + f := newFake() + f.lookup = map[string]bool{} + m := newManager(f) + if err := m.Apply(sample()); err != nil || len(f.calls) != 0 { + t.Errorf("nothing should run without samba/nfs: %v %v", err, f.calls) + } +} + +func TestUsers(t *testing.T) { + f := newFake() + f.reply["pdbedit -L"] = "alice:1001:\nbob:1002:Bob B\n" + f.fail["getent passwd carol"] = "" + f.reply["getent passwd bob"] = "bob:x:1002:1000::/nonexistent:/usr/sbin/nologin" + m := newManager(f) + users, err := m.ListUsers() + if err != nil || strings.Join(users, ",") != "alice,bob" { + t.Fatalf("list: %v %v", users, err) + } + if err := m.SetPassword("carol", "s3cret"); err != nil { + t.Fatal(err) + } + if !f.has("useradd -M -N -g 1000 -s /usr/sbin/nologin") { + t.Errorf("unix user not created: %v", f.calls) + } + if in := f.stdin["smbpasswd -s -a carol"]; in != "s3cret\ns3cret\n" { + t.Errorf("smbpasswd stdin = %q", in) + } + if err := m.SetPassword("Carol", "x"); err == nil { + t.Errorf("bad user name accepted") + } + if err := m.SetPassword("carol", "ab"); err == nil { + t.Errorf("short password accepted") + } + if err := m.DeleteUser("bob"); err != nil { + t.Fatal(err) + } + if !f.has("smbpasswd -x bob") || !f.has("userdel bob") { + t.Errorf("delete: %v", f.calls) + } + // the data owner keeps the unix account + f.reply["getent passwd ansible"] = "ansible:x:1000:1000::/home/ansible:/usr/sbin/nologin" + _ = m.DeleteUser("ansible") + if f.has("userdel ansible") { + t.Errorf("owner account deleted") + } +} diff --git a/internal/shares/validate.go b/internal/shares/validate.go new file mode 100644 index 0000000..2f3bcbd --- /dev/null +++ b/internal/shares/validate.go @@ -0,0 +1,152 @@ +package shares + +import ( + "errors" + "fmt" + "net" + "path" + "regexp" + "strings" +) + +var ( + reShareName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$`) + reWorkgroup = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,14}$`) + reUser = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) + reHost = regexp.MustCompile(`^[A-Za-z0-9*][A-Za-z0-9*.-]{0,253}$`) + reComment = regexp.MustCompile(`^[^\r\n\[\]]{0,120}$`) +) + +// reserved Samba section names that must not be used as share names. +var reservedShares = map[string]bool{"global": true, "homes": true, "printers": true, "print$": true} + +func ValidateShareName(s string) error { + if !reShareName.MatchString(s) { + return errors.New("share name: 1-80 characters, letters, digits, _ . - and must start with a letter or digit") + } + if reservedShares[strings.ToLower(s)] { + return fmt.Errorf("share name %q is reserved by Samba", s) + } + return nil +} + +func ValidateWorkgroup(s string) error { + if !reWorkgroup.MatchString(s) { + return errors.New("workgroup: 1-15 characters, letters, digits, _ -") + } + return nil +} + +func ValidateUserName(s string) error { + if !reUser.MatchString(s) { + return errors.New("user name: lowercase letters, digits, _ -, up to 32 characters") + } + if s == "root" || s == "nobody" { + return fmt.Errorf("user %s cannot be an SMB user", s) + } + return nil +} + +func ValidateComment(s string) error { + if !reComment.MatchString(s) { + return errors.New("comment: up to 120 characters, no [ ] or line breaks") + } + return nil +} + +// ValidatePath accepts an absolute, clean path without control characters. +// Whether it lies under a pool is checked by the caller (needs live state). +func ValidatePath(p string) error { + if p == "" { + return errors.New("path is required") + } + if !strings.HasPrefix(p, "/") || path.Clean(p) != p || strings.ContainsAny(p, "\"\\\r\n\t") { + return errors.New("path must be absolute and normalised (no .., trailing / or quotes)") + } + if p == "/" { + return errors.New("refusing to share the root filesystem") + } + return nil +} + +// UnderRoots reports whether p equals or lies below one of the roots. +func UnderRoots(p string, roots []string) bool { + for _, r := range roots { + if r == "" || r == "/" { + continue + } + if p == r || strings.HasPrefix(p, r+"/") { + return true + } + } + return false +} + +// ValidateClient accepts *, a host name (wildcards allowed), an IP or a CIDR. +func ValidateClient(c string) error { + if c == "*" { + return nil + } + if strings.Contains(c, "/") { + if _, _, err := net.ParseCIDR(c); err != nil { + return fmt.Errorf("client %q: bad network, use e.g. 192.168.1.0/24", c) + } + return nil + } + if net.ParseIP(c) != nil || reHost.MatchString(c) { + return nil + } + return fmt.Errorf("client %q: use a host name, IP address, network (CIDR) or *", c) +} + +func (s SMBShare) Validate() error { + if err := ValidateShareName(s.Name); err != nil { + return err + } + if err := ValidatePath(s.Path); err != nil { + return err + } + if err := ValidateComment(s.Comment); err != nil { + return err + } + if !s.Guest && len(s.Users) == 0 { + return fmt.Errorf("share %s: allow guests or pick at least one user", s.Name) + } + for _, u := range s.Users { + if err := ValidateUserName(u); err != nil { + return err + } + } + return nil +} + +func (e NFSExport) Validate() error { + if err := ValidatePath(e.Path); err != nil { + return err + } + if len(e.Clients) == 0 { + return fmt.Errorf("export %s: at least one client is required (or *)", e.Path) + } + for _, c := range e.Clients { + if err := ValidateClient(c); err != nil { + return err + } + } + switch e.Squash { + case "root", "all", "none": + default: + return errors.New("squash must be root, all or none") + } + return nil +} + +// SplitList turns "a, b;c\nd" into ["a","b","c","d"]. +func SplitList(s string) []string { + var out []string + for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ';' || r == '\n' || r == ' ' || r == '\t' || r == '\r' }) { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..81b4a47 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,403 @@ +// Package store persists SMART snapshots, alert state and self-test bookkeeping in SQLite. +package store + +import ( + "database/sql" + "encoding/json" + "time" + + _ "modernc.org/sqlite" + + "nasmon/internal/collect" +) + +type Store struct{ db *sql.DB } + +const schema = ` +CREATE TABLE IF NOT EXISTS smart_snapshots ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + serial TEXT NOT NULL, + device TEXT NOT NULL, + temp_c INTEGER, + healthy INTEGER, + power_on_hours INTEGER, + data TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS smart_snapshots_serial_ts ON smart_snapshots(serial, ts); + +CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY, + key TEXT NOT NULL, + level TEXT NOT NULL, + title TEXT NOT NULL, + detail TEXT NOT NULL, + opened_at INTEGER NOT NULL, + resolved_at INTEGER, + last_notified_at INTEGER, + acked_at INTEGER +); +CREATE INDEX IF NOT EXISTS alerts_key_open ON alerts(key, resolved_at); + +CREATE TABLE IF NOT EXISTS selftest_runs ( + serial TEXT NOT NULL, + kind TEXT NOT NULL, + started_at INTEGER NOT NULL, + PRIMARY KEY (serial, kind) +); + +CREATE TABLE IF NOT EXISTS disks_seen ( + serial TEXT PRIMARY KEY, + model TEXT NOT NULL, + device TEXT NOT NULL, + last_seen INTEGER NOT NULL, + missing_acked INTEGER NOT NULL DEFAULT 0 +); +` + +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, err + } + if err := migrate(db); err != nil { + db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +// migrate adds columns introduced after the first release. +func migrate(db *sql.DB) error { + rows, err := db.Query(`PRAGMA table_info(alerts)`) + if err != nil { + return err + } + defer rows.Close() + have := map[string]bool{} + for rows.Next() { + var cid int + var name, typ string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + return err + } + have[name] = true + } + if err := rows.Err(); err != nil { + return err + } + if !have["acked_at"] { + if _, err := db.Exec(`ALTER TABLE alerts ADD COLUMN acked_at INTEGER`); err != nil { + return err + } + } + return nil +} + +func (s *Store) Close() error { return s.db.Close() } + +// ---- SMART snapshots + +func (s *Store) AddSmart(sm collect.Smart) error { + if sm.Serial == "" { + return nil + } + b, err := json.Marshal(sm) + if err != nil { + return err + } + _, err = s.db.Exec(`INSERT INTO smart_snapshots(ts, serial, device, temp_c, healthy, power_on_hours, data) VALUES(?,?,?,?,?,?,?)`, + sm.CheckedAt.Unix(), sm.Serial, sm.Device, sm.TempC, sm.Healthy, sm.PowerOnHrs, string(b)) + return err +} + +// LastSmart returns the most recent stored snapshot for a serial. +func (s *Store) LastSmart(serial string) (*collect.Smart, error) { + var data string + err := s.db.QueryRow(`SELECT data FROM smart_snapshots WHERE serial=? ORDER BY ts DESC LIMIT 1`, serial).Scan(&data) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + var sm collect.Smart + if err := json.Unmarshal([]byte(data), &sm); err != nil { + return nil, err + } + return &sm, nil +} + +type SmartPoint struct { + T int64 `json:"t"` + TempC int `json:"temp_c"` + Healthy bool `json:"healthy"` + Hours int64 `json:"hours"` + Realloc int64 `json:"realloc"` // attr 5 raw or NVMe media errors + Pending int64 `json:"pending"` // attr 197 raw + CRC int64 `json:"crc"` // attr 199 raw +} + +// SmartHistory returns compact per-snapshot points since t. +func (s *Store) SmartHistory(serial string, since time.Time) ([]SmartPoint, error) { + rows, err := s.db.Query(`SELECT ts, data FROM smart_snapshots WHERE serial=? AND ts>=? ORDER BY ts`, serial, since.Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SmartPoint + for rows.Next() { + var ts int64 + var data string + if err := rows.Scan(&ts, &data); err != nil { + return nil, err + } + var sm collect.Smart + if json.Unmarshal([]byte(data), &sm) != nil { + continue + } + p := SmartPoint{T: ts, TempC: sm.TempC, Healthy: sm.Healthy, Hours: sm.PowerOnHrs} + if a, ok := sm.Attr(5); ok { + p.Realloc = a.Raw + } else if sm.NVMe != nil { + p.Realloc = sm.NVMe.MediaErrors + } + if a, ok := sm.Attr(197); ok { + p.Pending = a.Raw + } + if a, ok := sm.Attr(199); ok { + p.CRC = a.Raw + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) PruneSmart(olderThan time.Time) error { + _, err := s.db.Exec(`DELETE FROM smart_snapshots WHERE ts=? ORDER BY opened_at DESC LIMIT ?`, + resolvedSince.Unix(), limit) +} + +// CountResolvedBefore counts resolved alerts hidden by the default alerts view. +func (s *Store) CountResolvedBefore(t time.Time) (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE resolved_at IS NOT NULL AND resolved_at 0 { + mem[i] = float64(p.MemUsed) * 100 / float64(st.Mem.Total) + } + tmp[i] = p.CPUTemp + } + vm := dashVM{ + S: st, Th: cfg.Thresholds, + CPUSpark: sparkline(cpu, 100, "cpu"), + MemSpark: sparkline(mem, 100, "mem"), + TempSpark: sparkline(tmp, 100, "temp"), + } + if st.Mem.Total > 0 { + vm.MemPct = float64(st.Mem.Used) * 100 / float64(st.Mem.Total) + vm.ARCPct = float64(st.Mem.ARCSize) * 100 / float64(st.Mem.Total) + } + if st.Mem.SwapTotal > 0 { + vm.SwapPct = float64(st.Mem.SwapUsed) * 100 / float64(st.Mem.SwapTotal) + } + s.render(w, r, "dashboard", pageData{Title: "Dashboard", Data: vm, Alerts: len(st.Alerts), Flash: r.URL.Query().Get("msg")}) +} + +type diskVM struct { + D sched.DiskView + Th config.Thresholds + History []store.SmartPoint + HistoryRev []store.SmartPoint // newest first, capped + TempSpark template.HTML + Known bool +} + +func (s *Server) disk(w http.ResponseWriter, r *http.Request) { + serial := r.PathValue("serial") + st := s.sch.Status() + var vm diskVM + found := false + for _, d := range st.Disks { + if d.Serial == serial { + vm.D, found = d, true + } + } + if !found { + // disk may be gone: show what we know from the last snapshot + if sm, _ := s.st.LastSmart(serial); sm != nil { + vm.D = sched.DiskView{Smart: sm} + vm.D.Serial, vm.D.Model, vm.D.Path = sm.Serial, sm.Model, sm.Device + found = true + } + } + if !found { + http.NotFound(w, r) + return + } + vm.Th = s.cfg.Get().Thresholds + vm.History, _ = s.st.SmartHistory(serial, time.Now().Add(-30*24*time.Hour)) + temps := make([]float64, len(vm.History)) + for i, p := range vm.History { + temps[i] = float64(p.TempC) + } + vm.TempSpark = sparkline(temps, 0, "temp") + for i := len(vm.History) - 1; i >= 0 && len(vm.HistoryRev) < 100; i-- { + vm.HistoryRev = append(vm.HistoryRev, vm.History[i]) + } + if seen, _ := s.st.SeenDisks(); seen != nil { + for _, d := range seen { + if d.Serial == serial { + vm.Known = true + } + } + } + s.render(w, r, "disk", pageData{Title: "Disk " + vm.D.Model, Data: vm, Alerts: len(st.Alerts)}) +} + +func (s *Server) forgetDisk(w http.ResponseWriter, r *http.Request) { + _ = s.st.ForgetDisk(r.PathValue("serial")) + http.Redirect(w, r, "/?msg=Disk+forgotten", http.StatusSeeOther) +} + +// ---- ZFS page + dataset management + +// defaultOwner is pre-filled in the create form: uid/gid the Docker containers +// on this NAS run as, so a new dataset is writable for them right away. +const defaultOwner = "1000:1000" + +type zfsVM struct { + S sched.Status + Th config.Thresholds + Datasets []datasetRow + Form createForm // last submitted values (re-rendered after an error) +} + +type datasetRow struct { + collect.Dataset + CanDestroy bool + DomID string +} + +type createForm struct { + Pool, Name, Owner, Compression, Quota string +} + +func (s *Server) zfsView(st sched.Status, form createForm) zfsVM { + vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form} + if vm.Form.Owner == "" { + vm.Form.Owner = defaultOwner + } + for _, d := range st.Datasets { + row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)} + row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer + for _, o := range st.Datasets { + if strings.HasPrefix(o.Name, d.Name+"/") { + row.CanDestroy = false + } + } + vm.Datasets = append(vm.Datasets, row) + } + return vm +} + +func (s *Server) zfs(w http.ResponseWriter, r *http.Request) { + st := s.sch.Status() + s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Flash: r.URL.Query().Get("msg")}) +} + +func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f := createForm{ + Pool: strings.TrimSpace(r.FormValue("pool")), + Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"), + Owner: strings.TrimSpace(r.FormValue("owner")), + Compression: r.FormValue("compression"), + Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))), + } + st := s.sch.Status() + fail := func(msg string) { + s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, f), Alerts: len(st.Alerts), Error: "Not created: " + msg}) + } + if !hasPool(st, f.Pool) { + fail("unknown pool " + f.Pool) + return + } + if err := zfsadm.ValidateRelName(f.Name); err != nil { + fail(err.Error()) + return + } + full := f.Pool + "/" + f.Name + for _, d := range st.Datasets { + if d.Name == full { + fail(full + " already exists") + return + } + } + for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateSize(f.Quota)} { + if err != nil { + fail(err.Error()) + return + } + } + err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Quota: f.Quota, Owner: f.Owner}) + s.sch.RefreshStorage() + if err != nil { + log.Printf("zfs create %s: %v", full, err) + fail(err.Error()) + return + } + log.Printf("dataset %s created (owner %s, compression %q, quota %q)", full, f.Owner, f.Compression, f.Quota) + http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther) +} + +func (s *Server) zfsDestroy(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + st := s.sch.Status() + fail := func(msg string) { + s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Error: "Not destroyed: " + msg}) + } + if strings.TrimSpace(r.FormValue("confirm")) != name { + fail("type the dataset name to confirm") + return + } + eligible := false + for _, row := range s.zfsView(st, createForm{}).Datasets { + if row.Name == name { + eligible = row.CanDestroy + } + } + if !eligible { + fail(name + " is not an empty dataset") + return + } + err := s.admin.DestroyEmpty(name) + s.sch.RefreshStorage() + if err != nil { + log.Printf("zfs destroy %s: %v", name, err) + fail(err.Error()) + return + } + log.Printf("dataset %s destroyed", name) + http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther) +} + +func hasPool(st sched.Status, name string) bool { + for _, p := range st.Pools { + if p.Name == name { + return true + } + } + return false +} + +// ---- alerts + +// resolvedWindow is how long resolved alerts stay in the default /alerts view. +const resolvedWindow = 7 * 24 * time.Hour + +type alertsVM struct { + List []store.Alert + LastMailErr string + All bool + Hidden int // resolved alerts outside the window (only when !All) +} + +func (s *Server) alerts(w http.ResponseWriter, r *http.Request) { + vm := alertsVM{All: r.URL.Query().Get("all") == "1"} + since := time.Time{} + if !vm.All { + since = time.Now().Add(-resolvedWindow) + vm.Hidden, _ = s.st.CountResolvedBefore(since) + } + vm.List, _ = s.st.RecentAlerts(500, since) + st := s.sch.Status() + vm.LastMailErr = st.LastMailErr + s.render(w, r, "alerts", pageData{Title: "Alerts", Data: vm, Alerts: len(st.Alerts), Flash: r.URL.Query().Get("msg")}) +} + +// alertAction handles ack / unack / delete for one alert and sends the browser +// back where it came from. +func (s *Server) alertAction(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + a, err := s.st.GetAlert(id) + if err != nil || a == nil { + http.NotFound(w, r) + return + } + var msg string + switch r.PathValue("action") { + case "ack": + if a.ResolvedAt != nil { + http.Error(w, "alert is already resolved", http.StatusConflict) + return + } + err = s.st.AckAlert(id, time.Now()) + msg = "Alert acknowledged" + log.Printf("alert acknowledged: %s — %s", a.Key, a.Title) + case "unack": + err = s.st.UnackAlert(id) + msg = "Alert shown again" + case "delete": + if a.ResolvedAt == nil { + http.Error(w, "only resolved alerts can be deleted", http.StatusConflict) + return + } + err = s.st.DeleteAlert(id) + msg = "Alert removed from the log" + default: + http.NotFound(w, r) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.sch.RefreshAlerts() + if r.Header.Get("X-Requested-With") == "fetch" { + w.WriteHeader(http.StatusNoContent) + return + } + back := r.Header.Get("Referer") + if back == "" || !strings.HasPrefix(back, "/") && !strings.Contains(back, r.Host) { + back = "/alerts" + } + if u, err := url.Parse(back); err == nil { + q := u.Query() + q.Set("msg", msg) + u.RawQuery = q.Encode() + back = u.String() + } + http.Redirect(w, r, back, http.StatusSeeOther) +} + +func (s *Server) smartRun(w http.ResponseWriter, r *http.Request) { + s.sch.RunSmartNow() + http.Redirect(w, r, "/?msg=SMART+check+started", http.StatusSeeOther) +} + +// ---- settings + +type settingsVM struct { + C config.Settings + HasPass bool + ToText string +} + +func (s *Server) settings(w http.ResponseWriter, r *http.Request) { + c := s.cfg.Get() + s.render(w, r, "settings", pageData{Title: "Settings", Data: settingsVM{C: c, HasPass: c.SMTP.Pass != "", ToText: strings.Join(c.SMTP.To, "\n")}}) +} + +func (s *Server) settingsPost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + old := s.cfg.Get() + c := formToSettings(r, old) + pd := pageData{Title: "Settings"} + if err := s.cfg.Save(c); err != nil { + pd.Error = "Not saved: " + err.Error() + pd.Data = settingsVM{C: c, HasPass: c.SMTP.Pass != "", ToText: strings.Join(c.SMTP.To, "\n")} + s.render(w, r, "settings", pd) + return + } + s.sch.Reload() + pd.Flash = "Settings saved." + if r.FormValue("action") == "test" { + host := s.sch.Status().Host + body := "This is a test message from nasmon on " + host + ".\nIf you can read this, alert delivery works." + if err := s.send(c.SMTP, strings.TrimSpace(c.SMTP.SubjectPrefix+" test message from "+host), body); err != nil { + pd.Error = "Test mail failed: " + err.Error() + } else { + pd.Flash = "Settings saved, test mail sent to " + strings.Join(c.SMTP.To, ", ") + "." + } + } + saved := s.cfg.Get() + pd.Data = settingsVM{C: saved, HasPass: saved.SMTP.Pass != "", ToText: strings.Join(saved.SMTP.To, "\n")} + s.render(w, r, "settings", pd) +} + +func formToSettings(r *http.Request, old config.Settings) config.Settings { + num := func(name string, def int) int { + n, err := strconv.Atoi(strings.TrimSpace(r.FormValue(name))) + if err != nil { + return def + } + return n + } + on := func(name string) bool { return r.FormValue(name) != "" } + c := old + c.SMTP.Host = strings.TrimSpace(r.FormValue("smtp_host")) + c.SMTP.Port = num("smtp_port", 587) + c.SMTP.Security = r.FormValue("smtp_security") + c.SMTP.User = strings.TrimSpace(r.FormValue("smtp_user")) + if p := r.FormValue("smtp_pass"); p != "" { + c.SMTP.Pass = p + } + if on("smtp_clear_pass") { + c.SMTP.Pass = "" + } + c.SMTP.From = strings.TrimSpace(r.FormValue("smtp_from")) + c.SMTP.SubjectPrefix = strings.TrimSpace(r.FormValue("smtp_prefix")) + c.SMTP.To = nil + for _, line := range strings.FieldsFunc(r.FormValue("smtp_to"), func(r rune) bool { return r == '\n' || r == ',' || r == ';' || r == ' ' }) { + if line = strings.TrimSpace(line); line != "" { + c.SMTP.To = append(c.SMTP.To, line) + } + } + c.AlertsEnabled = on("alerts_enabled") + c.NotifyResolved = on("notify_resolved") + c.RemindHours = num("remind_hours", old.RemindHours) + c.SmartIntervalMin = num("smart_interval", old.SmartIntervalMin) + c.SmartSkipStandby = on("smart_skip_standby") + c.Thresholds.DiskTempC = num("th_disk_temp", old.Thresholds.DiskTempC) + c.Thresholds.CPUTempC = num("th_cpu_temp", old.Thresholds.CPUTempC) + c.Thresholds.PoolCapacityPct = num("th_pool_cap", old.Thresholds.PoolCapacityPct) + c.Thresholds.ScrubMaxAgeDays = num("th_scrub_days", old.Thresholds.ScrubMaxAgeDays) + c.Thresholds.RootFSFreePct = num("th_rootfs_free", old.Thresholds.RootFSFreePct) + c.Thresholds.NVMeUsedPct = num("th_nvme_used", old.Thresholds.NVMeUsedPct) + c.Thresholds.NVMeSparePct = num("th_nvme_spare", old.Thresholds.NVMeSparePct) + c.SelfTests.Enabled = on("st_enabled") + c.SelfTests.ShortDays = num("st_short_days", old.SelfTests.ShortDays) + c.SelfTests.LongDays = num("st_long_days", old.SelfTests.LongDays) + c.SelfTests.Hour = num("st_hour", old.SelfTests.Hour) + return c +} + +// ---- JSON API + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func (s *Server) apiStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, s.sch.Status()) +} + +func (s *Server) apiHistory(w http.ResponseWriter, r *http.Request) { + hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) + if hours <= 0 || hours > 24 { + hours = 24 + } + writeJSON(w, s.ring.Since(time.Now().Add(-time.Duration(hours)*time.Hour), 1000)) +} + +func (s *Server) apiDiskHistory(w http.ResponseWriter, r *http.Request) { + days, _ := strconv.Atoi(r.URL.Query().Get("days")) + if days <= 0 || days > 365 { + days = 30 + } + pts, err := s.st.SmartHistory(r.PathValue("serial"), time.Now().Add(-time.Duration(days)*24*time.Hour)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if pts == nil { + pts = []store.SmartPoint{} + } + writeJSON(w, pts) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..64846cc --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,284 @@ +// Package web serves the dashboard, settings form and JSON API. +package web + +import ( + "embed" + "fmt" + "html/template" + "io/fs" + "log" + "math" + "net/http" + "strings" + "sync" + "time" + + "nasmon/internal/alert" + "nasmon/internal/config" + "nasmon/internal/history" + "nasmon/internal/hostrun" + "nasmon/internal/sched" + "nasmon/internal/shares" + "nasmon/internal/store" + "nasmon/internal/zfsadm" +) + +//go:embed templates/*.html static/* +var assets embed.FS + +type Server struct { + cfg *config.Store + st *store.Store + ring *history.Ring + sch *sched.Scheduler + send func(config.SMTP, string, string) error + admin *zfsadm.Admin + shares *shares.Store + shm *shares.Manager + pages map[string]*template.Template + mux *http.ServeMux + + dirExists func(string) bool // share path check; replaced in tests + + mu sync.Mutex + shareErr string // last failed Apply of the share config +} + +func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Scheduler, sh *shares.Store) *Server { + run := hostrun.Auto() + s := &Server{cfg: cfg, st: st, ring: ring, sch: sch, send: alert.SendMail, + admin: zfsadm.New(zfsadm.NoInput(run)), shares: sh, shm: shares.New(run), dirExists: statDir, mux: http.NewServeMux()} + s.pages = map[string]*template.Template{} + layout := template.Must(template.New("layout").Funcs(funcs).ParseFS(assets, "templates/layout.html")) + for _, name := range []string{"dashboard", "disk", "zfs", "alerts", "settings", "shares"} { + t := template.Must(template.Must(layout.Clone()).ParseFS(assets, "templates/"+name+".html")) + s.pages[name] = t + } + static, _ := fs.Sub(assets, "static") + // embedded files carry no modtime, so tell browsers to re-fetch them: + // otherwise a stale app.js can survive an upgrade until a hard reload + files := http.StripPrefix("/static/", http.FileServer(http.FS(static))) + s.mux.Handle("GET /static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + files.ServeHTTP(w, r) + })) + s.mux.HandleFunc("GET /{$}", s.dashboard) + s.mux.HandleFunc("GET /disks/{serial}", s.disk) + s.mux.HandleFunc("POST /disks/{serial}/forget", s.forgetDisk) + s.mux.HandleFunc("GET /zfs", s.zfs) + s.mux.HandleFunc("POST /zfs/create", s.zfsCreate) + s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy) + s.mux.HandleFunc("GET /alerts", s.alerts) + s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction) + s.mux.HandleFunc("GET /shares", s.sharesPage) + s.mux.HandleFunc("POST /shares/apply", s.sharesApply) + s.mux.HandleFunc("POST /shares/smb/settings", s.smbSettings) + s.mux.HandleFunc("POST /shares/smb/save", s.smbSave) + s.mux.HandleFunc("POST /shares/smb/delete", s.smbDelete) + s.mux.HandleFunc("POST /shares/nfs/settings", s.nfsSettings) + s.mux.HandleFunc("POST /shares/nfs/save", s.nfsSave) + s.mux.HandleFunc("POST /shares/nfs/delete", s.nfsDelete) + s.mux.HandleFunc("POST /shares/users/save", s.userSave) + s.mux.HandleFunc("POST /shares/users/delete", s.userDelete) + s.mux.HandleFunc("GET /settings", s.settings) + s.mux.HandleFunc("POST /settings", s.settingsPost) + s.mux.HandleFunc("POST /smart/run", s.smartRun) + s.mux.HandleFunc("GET /api/status", s.apiStatus) + s.mux.HandleFunc("GET /api/history", s.apiHistory) + s.mux.HandleFunc("GET /api/disks/{serial}/history", s.apiDiskHistory) + return s +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } + +type pageData struct { + Title string + Page string + Partial bool + Alerts int + Data any + Flash string + Error string +} + +// render writes a full page, or only the "content" block when ?partial=1 is +// requested by the auto-refresh script. +func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, pd pageData) { + pd.Page = name + pd.Partial = r.URL.Query().Get("partial") == "1" + if pd.Alerts == 0 { + pd.Alerts = len(s.sch.Status().Alerts) + } + t := s.pages[name] + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Alerts-Open", fmt.Sprint(pd.Alerts)) + block := "layout" + if pd.Partial { + block = "content" + } + if err := t.ExecuteTemplate(w, block, pd); err != nil { + log.Printf("render %s: %v", name, err) + } +} + +// ---- template helpers + +var funcs = template.FuncMap{ + "bytes": humanBytes, + "pct": func(part, total uint64) float64 { + if total == 0 { + return 0 + } + return math.Round(float64(part)*1000/float64(total)) / 10 + }, + "f1": func(f float64) string { return fmt.Sprintf("%.1f", f) }, + "f0": func(f float64) string { return fmt.Sprintf("%.0f", f) }, + "ago": ago, + "agop": func(t *time.Time) string { + if t == nil { + return "never" + } + return ago(*t) + }, + "capClass": func(pct, warn, crit int) string { + switch { + case pct >= crit: + return "crit" + case pct >= warn: + return "warn" + } + return "ok" + }, + "dt": func(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.Local().Format("2006-01-02 15:04") + }, + "dtp": func(t *time.Time) string { + if t == nil { + return "—" + } + return t.Local().Format("2006-01-02 15:04") + }, + "hours": func(h int64) string { return fmt.Sprintf("%d h (%.1f y)", h, float64(h)/8760) }, + "dur": func(sec float64) string { return (time.Duration(sec) * time.Second).Truncate(time.Minute).String() }, + "levelClass": func(pct float64, warn, crit float64) string { + switch { + case pct >= crit: + return "crit" + case pct >= warn: + return "warn" + } + return "ok" + }, + "tempClass": func(v any, warn int) string { + c := toFloat(v) + switch { + case warn > 0 && c >= float64(warn): + return "crit" + case warn > 0 && c >= float64(warn)-8: + return "warn" + } + return "ok" + }, + "healthClass": func(h string) string { + switch h { + case "ONLINE": + return "ok" + case "DEGRADED": + return "warn" + } + return "crit" + }, + "unix": func(t int64) time.Time { return time.Unix(t, 0) }, + "join": strings.Join, + "hasPrefix": strings.HasPrefix, + "contains": func(list []string, x string) bool { + for _, v := range list { + if v == x { + return true + } + } + return false + }, + "repeat": strings.Repeat, + "lines": func(s string) []string { return strings.Split(s, "\n") }, + "seq": func(n int) []int { + out := make([]int, n) + for i := range out { + out[i] = i + } + return out + }, +} + +func toFloat(v any) float64 { + switch n := v.(type) { + case int: + return float64(n) + case int64: + return float64(n) + case float64: + return n + } + return 0 +} + +func humanBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +func ago(t time.Time) string { + if t.IsZero() { + return "never" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%d min ago", int(d.Minutes())) + case d < 48*time.Hour: + return fmt.Sprintf("%d h ago", int(d.Hours())) + } + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) +} + +// sparkline renders values as an inline SVG polyline. max<=0 means autoscale. +func sparkline(vals []float64, max float64, class string) template.HTML { + const w, h = 240.0, 40.0 + if len(vals) < 2 { + return template.HTML(fmt.Sprintf(``, class, int(w), int(h))) + } + if max <= 0 { + for _, v := range vals { + if v > max { + max = v + } + } + if max == 0 { + max = 1 + } + max *= 1.1 + } + var pts strings.Builder + for i, v := range vals { + x := w * float64(i) / float64(len(vals)-1) + y := h - h*math.Min(v, max)/max + fmt.Fprintf(&pts, "%.1f,%.1f ", x, y) + } + p := strings.TrimSpace(pts.String()) + return template.HTML(fmt.Sprintf( + ``, + class, int(w), int(h), int(h), p, int(w), int(h), p)) +} diff --git a/internal/web/shares.go b/internal/web/shares.go new file mode 100644 index 0000000..7ff7870 --- /dev/null +++ b/internal/web/shares.go @@ -0,0 +1,372 @@ +package web + +import ( + "log" + "net/http" + "net/url" + "os" + "sort" + "strings" + + "nasmon/internal/collect" + "nasmon/internal/sched" + "nasmon/internal/shares" +) + +// ---- /shares: SMB + NFS management + +type smbForm struct { + Orig, Name, Path, Comment string + ReadOnly, Guest, Browseable, HideSys bool + Users []string + New bool +} + +type nfsForm struct { + Orig, Path, Clients, Squash string + ReadOnly, Async bool + New bool +} + +type sharesVM struct { + C shares.Config + Svc collect.ShareServices + Users []string + Mountpoints []string + Owner string + Host string + Form smbForm + NForm nfsForm + ApplyErr string // last Apply failure: config saved but not live + Squashes []string +} + +func (s *Server) applyErr() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.shareErr +} + +func (s *Server) setApplyErr(e string) { + s.mu.Lock() + s.shareErr = e + s.mu.Unlock() +} + +// mountpoints lists dataset mountpoints — the only places shares may point to. +func mountpoints(st sched.Status) []string { + var out []string + for _, d := range st.Datasets { + if strings.HasPrefix(d.Mountpoint, "/") { + out = append(out, d.Mountpoint) + } + } + sort.Strings(out) + return out +} + +func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) sharesVM { + c := s.shares.Get() + vm := sharesVM{C: c, Svc: st.Services, Mountpoints: mountpoints(st), Owner: s.shm.Owner, Host: st.Host, + Form: form, NForm: nform, ApplyErr: s.applyErr(), Squashes: []string{"root", "all", "none"}} + if vm.Form.Name == "" && vm.Form.Orig == "" { + vm.Form.New, vm.Form.Guest, vm.Form.Browseable, vm.Form.HideSys = true, true, true, true + } + if vm.NForm.Path == "" && vm.NForm.Orig == "" { + vm.NForm.New = true + if vm.NForm.Squash == "" { + vm.NForm.Squash = "root" + } + } + if u, err := s.shm.ListUsers(); err == nil { + vm.Users = u + } else { + log.Printf("shares: list users: %v", err) + } + return vm +} + +func smbToForm(sh shares.SMBShare) smbForm { + return smbForm{Orig: sh.Name, Name: sh.Name, Path: sh.Path, Comment: sh.Comment, ReadOnly: sh.ReadOnly, + Guest: sh.Guest, Browseable: sh.Browseable, HideSys: sh.HideSysFiles, Users: sh.Users} +} + +func nfsToForm(e shares.NFSExport) nfsForm { + return nfsForm{Orig: e.Path, Path: e.Path, Clients: strings.Join(e.Clients, ", "), Squash: e.Squash, ReadOnly: e.ReadOnly, Async: e.Async} +} + +func (s *Server) sharesPage(w http.ResponseWriter, r *http.Request) { + st := s.sch.Status() + c := s.shares.Get() + q := r.URL.Query() + var form smbForm + var nform nfsForm + if i := c.FindSMB(q.Get("edit")); i >= 0 { + form = smbToForm(c.SMB.Shares[i]) + } + if i := c.FindNFS(q.Get("nedit")); i >= 0 { + nform = nfsToForm(c.NFS.Exports[i]) + } + if p := q.Get("path"); p != "" { // "share…" link from the ZFS page + if form.Orig == "" { + form.Path = p + form.Name = strings.Trim(p[strings.LastIndex(p, "/")+1:], "/") + } + if nform.Orig == "" { + nform.Path = p + } + } + s.render(w, r, "shares", pageData{Title: "Shares", Data: s.sharesView(st, form, nform), Alerts: len(st.Alerts), Flash: q.Get("msg")}) +} + +// saveAndApply persists the config and pushes it to the daemons. Returns the +// message for the user and whether everything went live. +func (s *Server) saveAndApply(c shares.Config) error { + if err := s.shares.Save(c); err != nil { + return err + } + if err := s.shm.Apply(c); err != nil { + s.setApplyErr(err.Error()) + log.Printf("shares: apply failed: %v", err) + return nil + } + s.setApplyErr("") + s.sch.RefreshStorage() + return nil +} + +func (s *Server) sharesFail(w http.ResponseWriter, r *http.Request, msg string, form smbForm, nform nfsForm) { + st := s.sch.Status() + s.render(w, r, "shares", pageData{Title: "Shares", Data: s.sharesView(st, form, nform), Alerts: len(st.Alerts), Error: msg}) +} + +func (s *Server) sharesDone(w http.ResponseWriter, r *http.Request, msg, anchor string) { + if e := s.applyErr(); e != "" { + msg += " — but applying failed: " + e + } + http.Redirect(w, r, "/shares?msg="+url.QueryEscape(msg)+anchor, http.StatusSeeOther) +} + +// pathOK checks that a share path exists and lies inside a ZFS dataset. +func (s *Server) pathOK(st sched.Status, p string) error { + if err := shares.ValidatePath(p); err != nil { + return err + } + if !shares.UnderRoots(p, mountpoints(st)) { + return errPathOutside + } + if !s.dirExists(p) { + return errPathMissing + } + return nil +} + +func statDir(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + +type strErr string + +func (e strErr) Error() string { return string(e) } + +const ( + errPathOutside = strErr("path must be inside a ZFS dataset (see the list of mountpoints)") + errPathMissing = strErr("path does not exist or is not a directory") +) + +func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + c := s.shares.Get() + c.SMB.Enabled = r.FormValue("enabled") != "" + c.SMB.Workgroup = strings.ToUpper(strings.TrimSpace(r.FormValue("workgroup"))) + c.SMB.ServerString = strings.TrimSpace(r.FormValue("server_string")) + if err := shares.ValidateComment(c.SMB.ServerString); err != nil { + s.sharesFail(w, r, "Not saved: server description: "+err.Error(), smbForm{}, nfsForm{}) + return + } + if err := s.saveAndApply(c); err != nil { + s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, nfsForm{}) + return + } + s.sharesDone(w, r, "SMB settings saved", "#smb") +} + +func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f := smbForm{ + Orig: strings.TrimSpace(r.FormValue("orig")), Name: strings.TrimSpace(r.FormValue("name")), + Path: strings.TrimSpace(r.FormValue("path")), Comment: strings.TrimSpace(r.FormValue("comment")), + ReadOnly: r.FormValue("read_only") != "", Guest: r.FormValue("guest") != "", + Browseable: r.FormValue("browseable") != "", HideSys: r.FormValue("hide_sys") != "", + Users: r.Form["users"], + } + if len(f.Path) > 1 { + f.Path = strings.TrimRight(f.Path, "/") + } + st := s.sch.Status() + sh := shares.SMBShare{Name: f.Name, Path: f.Path, Comment: f.Comment, ReadOnly: f.ReadOnly, Guest: f.Guest, + Users: f.Users, Browseable: f.Browseable, HideSysFiles: f.HideSys} + if sh.Guest { + sh.Users = nil + } + if err := sh.Validate(); err != nil { + s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{}) + return + } + if err := s.pathOK(st, sh.Path); err != nil { + s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{}) + return + } + c := s.shares.Get() + if i := c.FindSMB(f.Orig); f.Orig != "" && i >= 0 { + if sh.Name != f.Orig && c.FindSMB(sh.Name) >= 0 { + s.sharesFail(w, r, "Not saved: share "+sh.Name+" already exists", f, nfsForm{}) + return + } + c.SMB.Shares[i] = sh + } else { + if c.FindSMB(sh.Name) >= 0 { + s.sharesFail(w, r, "Not saved: share "+sh.Name+" already exists — edit it instead", f, nfsForm{}) + return + } + c.SMB.Shares = append(c.SMB.Shares, sh) + } + if err := s.saveAndApply(c); err != nil { + s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{}) + return + } + log.Printf("shares: SMB share %s -> %s saved (guest=%v users=%v ro=%v)", sh.Name, sh.Path, sh.Guest, sh.Users, sh.ReadOnly) + s.sharesDone(w, r, "Share "+sh.Name+" saved", "#smb") +} + +func (s *Server) smbDelete(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.FormValue("name")) + c := s.shares.Get() + i := c.FindSMB(name) + if i < 0 { + s.sharesFail(w, r, "No such share: "+name, smbForm{}, nfsForm{}) + return + } + c.SMB.Shares = append(c.SMB.Shares[:i], c.SMB.Shares[i+1:]...) + if err := s.saveAndApply(c); err != nil { + s.sharesFail(w, r, "Not deleted: "+err.Error(), smbForm{}, nfsForm{}) + return + } + log.Printf("shares: SMB share %s deleted", name) + s.sharesDone(w, r, "Share "+name+" deleted", "#smb") +} + +func (s *Server) nfsSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f := nfsForm{ + Orig: strings.TrimSpace(r.FormValue("orig")), Path: strings.TrimSpace(r.FormValue("path")), + Clients: strings.TrimSpace(r.FormValue("clients")), Squash: r.FormValue("squash"), + ReadOnly: r.FormValue("read_only") != "", Async: r.FormValue("async") != "", + } + if len(f.Path) > 1 { + f.Path = strings.TrimRight(f.Path, "/") + } + st := s.sch.Status() + e := shares.NFSExport{Path: f.Path, Clients: shares.SplitList(f.Clients), ReadOnly: f.ReadOnly, Squash: f.Squash, Async: f.Async} + if err := e.Validate(); err != nil { + s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f) + return + } + if err := s.pathOK(st, e.Path); err != nil { + s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f) + return + } + c := s.shares.Get() + if i := c.FindNFS(f.Orig); f.Orig != "" && i >= 0 { + if e.Path != f.Orig && c.FindNFS(e.Path) >= 0 { + s.sharesFail(w, r, "Not saved: "+e.Path+" is already exported", smbForm{}, f) + return + } + c.NFS.Exports[i] = e + } else { + if c.FindNFS(e.Path) >= 0 { + s.sharesFail(w, r, "Not saved: "+e.Path+" is already exported — edit it instead", smbForm{}, f) + return + } + c.NFS.Exports = append(c.NFS.Exports, e) + } + if err := s.saveAndApply(c); err != nil { + s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f) + return + } + log.Printf("shares: NFS export %s to %v saved (ro=%v squash=%s)", e.Path, e.Clients, e.ReadOnly, e.Squash) + s.sharesDone(w, r, "Export "+e.Path+" saved", "#nfs") +} + +func (s *Server) nfsDelete(w http.ResponseWriter, r *http.Request) { + p := strings.TrimSpace(r.FormValue("path")) + c := s.shares.Get() + i := c.FindNFS(p) + if i < 0 { + s.sharesFail(w, r, "No such export: "+p, smbForm{}, nfsForm{}) + return + } + c.NFS.Exports = append(c.NFS.Exports[:i], c.NFS.Exports[i+1:]...) + if err := s.saveAndApply(c); err != nil { + s.sharesFail(w, r, "Not deleted: "+err.Error(), smbForm{}, nfsForm{}) + return + } + log.Printf("shares: NFS export %s deleted", p) + s.sharesDone(w, r, "Export "+p+" deleted", "#nfs") +} + +func (s *Server) nfsSettings(w http.ResponseWriter, r *http.Request) { + c := s.shares.Get() + c.NFS.Enabled = r.FormValue("enabled") != "" + if err := s.saveAndApply(c); err != nil { + s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, nfsForm{}) + return + } + s.sharesDone(w, r, "NFS settings saved", "#nfs") +} + +func (s *Server) userSave(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.FormValue("name")) + pass := r.FormValue("password") + if err := s.shm.SetPassword(name, pass); err != nil { + s.sharesFail(w, r, "User not saved: "+err.Error(), smbForm{}, nfsForm{}) + return + } + http.Redirect(w, r, "/shares?msg="+url.QueryEscape("Password set for "+name)+"#users", http.StatusSeeOther) +} + +func (s *Server) userDelete(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.FormValue("name")) + c := s.shares.Get() + for _, sh := range c.SMB.Shares { + for _, u := range sh.Users { + if u == name { + s.sharesFail(w, r, "User "+name+" is still allowed on share "+sh.Name+" — remove them there first", smbForm{}, nfsForm{}) + return + } + } + } + if err := s.shm.DeleteUser(name); err != nil { + s.sharesFail(w, r, "User not deleted: "+err.Error(), smbForm{}, nfsForm{}) + return + } + http.Redirect(w, r, "/shares?msg="+url.QueryEscape("User "+name+" deleted")+"#users", http.StatusSeeOther) +} + +func (s *Server) sharesApply(w http.ResponseWriter, r *http.Request) { + if err := s.saveAndApply(s.shares.Get()); err != nil { + s.sharesFail(w, r, err.Error(), smbForm{}, nfsForm{}) + return + } + s.sharesDone(w, r, "Configuration applied", "") +} diff --git a/internal/web/shares_test.go b/internal/web/shares_test.go new file mode 100644 index 0000000..f6c718c --- /dev/null +++ b/internal/web/shares_test.go @@ -0,0 +1,212 @@ +package web + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "testing" + + "nasmon/internal/shares" +) + +// shareRunner fakes the host runner for the shares manager. +type shareRunner struct { + calls []string + stdin map[string]string + fail string + users string +} + +func (f *shareRunner) run(_ context.Context, stdin, name string, args ...string) (string, error) { + c := name + " " + strings.Join(args, " ") + f.calls = append(f.calls, c) + if stdin != "" { + if f.stdin == nil { + f.stdin = map[string]string{} + } + f.stdin[c] = stdin + } + if f.fail != "" && strings.HasPrefix(c, f.fail) { + return "boom: " + f.fail, errors.New("boom: " + f.fail) + } + if strings.HasPrefix(c, "pdbedit -L") { + return f.users, nil + } + return "", nil +} + +func (f *shareRunner) has(prefix string) bool { + for _, c := range f.calls { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +func (f *shareRunner) written(path string) string { + for k, v := range f.stdin { + if strings.Contains(k, path) { + return v + } + } + return "" +} + +func withShareRunner(srv *Server, f *shareRunner) { + srv.shm = shares.New(f.run) + srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil } + srv.shm.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000" +} + +func TestSharesSMBFlow(t *testing.T) { + srv, _, _ := newTestServer(t) + f := &shareRunner{users: "alice:1001:\n"} + withShareRunner(srv, f) + + // page lists users and mountpoints, prefills from ?path= + body := get(srv, "/shares?path=/tank/data").Body.String() + for _, want := range []string{`value="/tank/data"`, `value="data"`, `value="alice"`, "No SMB shares yet"} { + if !strings.Contains(body, want) { + t.Errorf("shares page missing %q", want) + } + } + + // create a guest share (trailing slash is trimmed) + rec := postForm(srv, "/shares/smb/save", url.Values{"name": {"data"}, "path": {"/tank/data/"}, "comment": {"Stuff"}, "guest": {"1"}, "browseable": {"1"}, "hide_sys": {"1"}}) + if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "Share+data+saved") { + t.Fatalf("create: %d %s %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) + } + c := srv.shares.Get() + if len(c.SMB.Shares) != 1 || c.SMB.Shares[0].Path != "/tank/data" || !c.SMB.Shares[0].Guest { + t.Fatalf("saved config: %+v", c.SMB.Shares) + } + if !f.has("testparm -s") || !f.has("systemctl enable --now smbd nmbd") || !f.has("smbcontrol all reload-config") { + t.Errorf("apply calls: %v", f.calls) + } + conf := f.written(shares.DefaultSMBConf) + for _, want := range []string{"[data]", "path = /tank/data", "guest ok = yes", "force user = ansible", "veto files"} { + if !strings.Contains(conf, want) { + t.Errorf("smb.conf missing %q:\n%s", want, conf) + } + } + if !strings.Contains(get(srv, "/shares").Body.String(), "data") { + t.Errorf("share not listed") + } + + // refused: duplicate name, path outside pools, non-guest without users, reserved name, unclean path + for _, form := range []url.Values{ + {"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}}, + {"name": {"etc"}, "path": {"/etc"}, "guest": {"1"}}, + {"name": {"priv"}, "path": {"/tank/data"}}, + {"name": {"global"}, "path": {"/tank/data"}, "guest": {"1"}}, + {"name": {"gone"}, "path": {"/tank/nope/.."}, "guest": {"1"}}, + } { + rec = postForm(srv, "/shares/smb/save", form) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") { + t.Errorf("form %v accepted: %d", form, rec.Code) + } + } + if len(srv.shares.Get().SMB.Shares) != 1 { + t.Fatalf("invalid share stored") + } + + // edit: rename + restrict to alice + rec = postForm(srv, "/shares/smb/save", url.Values{"orig": {"data"}, "name": {"private"}, "path": {"/tank/data"}, "users": {"alice"}, "read_only": {"1"}, "browseable": {"1"}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("edit: %d %s", rec.Code, rec.Body.String()) + } + c = srv.shares.Get() + if len(c.SMB.Shares) != 1 || c.SMB.Shares[0].Name != "private" || c.SMB.Shares[0].Guest || strings.Join(c.SMB.Shares[0].Users, ",") != "alice" || !c.SMB.Shares[0].ReadOnly { + t.Fatalf("edited share: %+v", c.SMB.Shares) + } + if !strings.Contains(f.written(shares.DefaultSMBConf), "valid users = alice") { + t.Errorf("smb.conf not regenerated after edit") + } + // a user still referenced by a share cannot be deleted + rec = postForm(srv, "/shares/users/delete", url.Values{"name": {"alice"}}) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "still allowed on share private") || f.has("smbpasswd -x") { + t.Errorf("user deletion while in use: %d", rec.Code) + } + // add a user: password goes to smbpasswd on stdin + rec = postForm(srv, "/shares/users/save", url.Values{"name": {"bob"}, "password": {"hunter2"}}) + if rec.Code != http.StatusSeeOther || f.stdin["smbpasswd -s -a bob"] != "hunter2\nhunter2\n" { + t.Errorf("add user: %d %v", rec.Code, f.stdin) + } + // delete the last share: smbd stopped + rec = postForm(srv, "/shares/smb/delete", url.Values{"name": {"private"}}) + if rec.Code != http.StatusSeeOther || len(srv.shares.Get().SMB.Shares) != 0 { + t.Errorf("delete: %d", rec.Code) + } + if !f.has("systemctl disable --now smbd nmbd") { + t.Errorf("smbd not stopped after last share removed: %v", f.calls) + } +} + +func TestSharesApplyFailureIsReported(t *testing.T) { + srv, _, _ := newTestServer(t) + f := &shareRunner{fail: "testparm"} + withShareRunner(srv, f) + rec := postForm(srv, "/shares/smb/save", url.Values{"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}, "browseable": {"1"}}) + loc := rec.Header().Get("Location") + if rec.Code != http.StatusSeeOther || !strings.Contains(loc, "applying+failed") { + t.Fatalf("expected redirect with failure notice: %d %s", rec.Code, loc) + } + if len(srv.shares.Get().SMB.Shares) != 1 { + t.Errorf("config not saved") + } + body := get(srv, "/shares").Body.String() + if !strings.Contains(body, "not applied") || !strings.Contains(body, "/shares/apply") { + t.Errorf("pending state not shown") + } + // fix the runner and re-apply + f.fail = "" + rec = postForm(srv, "/shares/apply", nil) + if rec.Code != http.StatusSeeOther || strings.Contains(rec.Header().Get("Location"), "failed") { + t.Errorf("re-apply: %d %s", rec.Code, rec.Header().Get("Location")) + } + if strings.Contains(get(srv, "/shares").Body.String(), "not applied") { + t.Errorf("pending state still shown") + } +} + +func TestSharesNFSFlow(t *testing.T) { + srv, _, _ := newTestServer(t) + f := &shareRunner{} + withShareRunner(srv, f) + rec := postForm(srv, "/shares/nfs/save", url.Values{"path": {"/tank/data"}, "clients": {"192.168.101.0/24, pc.lan"}, "squash": {"all"}, "async": {"1"}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + exp := f.written(shares.DefaultExports) + if !strings.Contains(exp, `"/tank/data" 192.168.101.0/24(rw,async,all_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check) pc.lan(`) { + t.Errorf("exports:\n%s", exp) + } + if !f.has("systemctl enable --now nfs-server") || !f.has("exportfs -ra") { + t.Errorf("nfs apply calls: %v", f.calls) + } + for _, form := range []url.Values{ + {"path": {"/tank/data"}, "clients": {"*"}, "squash": {"root"}}, // duplicate + {"path": {"/tank/data2"}, "clients": {""}, "squash": {"root"}}, // no clients + {"path": {"/tank/data2"}, "clients": {"10.0.0.0/33"}, "squash": {"root"}}, // bad cidr + {"path": {"/tank/data2"}, "clients": {"*"}, "squash": {"weird"}}, // bad squash + } { + if rec := postForm(srv, "/shares/nfs/save", form); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") { + t.Errorf("form %v accepted", form) + } + } + body := get(srv, "/shares").Body.String() + if !strings.Contains(body, "192.168.101.0/24, pc.lan") || !strings.Contains(body, ":/tank/data") { + t.Errorf("export not listed") + } + body = get(srv, "/shares?nedit=/tank/data").Body.String() + if !strings.Contains(body, "Edit export /tank/data") || !strings.Contains(body, `value="192.168.101.0/24, pc.lan"`) { + t.Errorf("edit form not prefilled") + } + rec = postForm(srv, "/shares/nfs/delete", url.Values{"path": {"/tank/data"}}) + if rec.Code != http.StatusSeeOther || len(srv.shares.Get().NFS.Exports) != 0 || !f.has("systemctl disable --now nfs-server") { + t.Errorf("delete: %d %v", rec.Code, f.calls) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css new file mode 100644 index 0000000..9babd96 --- /dev/null +++ b/internal/web/static/app.css @@ -0,0 +1,122 @@ +:root { + --bg: #f4f6f9; --card: #ffffff; --fg: #1d2330; --muted: #6b7280; --line: #e3e7ee; + --accent: #2d7ff9; --ok: #16a34a; --warn: #d97706; --crit: #dc2626; + --ok-bg: #dcfce7; --warn-bg: #fef3c7; --crit-bg: #fee2e2; --badge-bg: #e5e7eb; + --mem: #2d7ff9; --arc: #8b5cf6; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1419; --card: #171d26; --fg: #e5e9f0; --muted: #8b95a5; --line: #263041; + --ok-bg: #14532d55; --warn-bg: #78350f55; --crit-bg: #7f1d1d55; --badge-bg: #2a3444; + } +} +* { box-sizing: border-box; } +html { color-scheme: light dark; } +body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +header.top { display: flex; align-items: center; gap: 24px; padding: 10px 20px; background: var(--card); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; } +.brand { font-weight: 700; font-size: 16px; color: var(--fg); letter-spacing: .02em; } +nav { display: flex; gap: 4px; flex: 1; } +nav a { color: var(--muted); padding: 6px 10px; border-radius: 6px; } +nav a.on, nav a:hover { color: var(--fg); background: var(--bg); text-decoration: none; } +.refresh { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); opacity: .6; } +.refresh.stale { background: var(--warn); } +.refresh.paused { background: var(--muted); } + +main { max-width: 1280px; margin: 0 auto; padding: 16px 20px 40px; } +.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 18px; margin-bottom: 16px; } +.card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; display: flex; align-items: baseline; gap: 10px; } +.card h2 .muted { font-weight: 400; font-size: 13px; } +.grid.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-bottom: 16px; } +.grid.metrics .card { margin: 0; } +.metric .big { font-size: 32px; font-weight: 600; line-height: 1.1; margin: 2px 0 4px; } +.muted { color: var(--muted); } +.small { font-size: 12px; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.num { text-align: right; font-variant-numeric: tabular-nums; } +.ok { color: var(--ok); } .warn { color: var(--warn); } .crit { color: var(--crit); } +.crit-text { color: var(--crit); } .warn-text { color: var(--warn); } +.kv div { margin: 2px 0; } +.kv .muted { display: inline-block; min-width: 110px; } +.kv.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 20px; } + +.badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--badge-bg); color: var(--fg); vertical-align: middle; text-transform: uppercase; letter-spacing: .03em; } +.badge.ok { background: var(--ok-bg); color: var(--ok); } +.badge.warn { background: var(--warn-bg); color: var(--warn); } +.badge.crit { background: var(--crit-bg); color: var(--crit); } + +.bar { position: relative; height: 10px; background: var(--badge-bg); border-radius: 5px; overflow: hidden; margin: 6px 0; } +.bar.small { display: inline-block; width: 80px; height: 8px; margin: 0; vertical-align: middle; } +.bar .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); } +.bar .fill.mem { background: var(--mem); } +.bar .fill.arc { background: var(--arc); } +.bar .fill.ok { background: var(--ok); } .bar .fill.warn { background: var(--warn); } .bar .fill.crit { background: var(--crit); } +.arc-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; background: var(--arc); margin-right: 4px; } + +svg.spark { display: block; width: 100%; height: 40px; margin-top: 8px; } +svg.spark polyline { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; } +svg.spark polygon { fill: var(--accent); opacity: .12; } +svg.spark.temp polyline { stroke: var(--warn); } svg.spark.temp polygon { fill: var(--warn); } +svg.spark.mem polyline { stroke: var(--mem); } svg.spark.mem polygon { fill: var(--mem); } + +.tablewrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } +th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } +tbody tr:last-child td { border-bottom: 0; } +table.compact th, table.compact td { padding: 3px 8px; } +tr.row-crit td { background: var(--crit-bg); } +details.sensors { margin-top: 6px; } +details.sensors summary { cursor: pointer; } + +.flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; background: var(--badge-bg); } +.flash.ok { background: var(--ok-bg); color: var(--ok); } +.flash.crit { background: var(--crit-bg); color: var(--crit); } +.alerts-open { border-color: var(--crit); } +.alert { padding: 8px 28px 8px 0; border-top: 1px solid var(--line); position: relative; } +.alert.acked .alert-title { color: var(--muted); } +.x-form { display: inline; } +.alert .x-form:not(.inline) { position: absolute; top: 6px; right: 0; } +button.x { padding: 0 6px; line-height: 20px; font-size: 18px; background: transparent; border: 0; color: var(--muted); } +button.x:hover { color: var(--crit); } +button.link { padding: 0; border: 0; background: none; color: var(--accent); font-size: inherit; } +button.link:hover { text-decoration: underline; } +.alert:first-of-type { border-top: 0; } +.alert-title { font-weight: 600; } +.alert.resolved .alert-title { color: var(--muted); font-weight: 500; } +.alert-detail { margin: 4px 0; font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace; white-space: pre-wrap; color: var(--muted); } + +button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; } +button.secondary { background: transparent; color: var(--accent); } +button:disabled { opacity: .5; cursor: default; } +form.inline { display: inline; margin-top: 8px; } +.actions { display: flex; gap: 10px; } + +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 10px 24px; } +.form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); } +.form-grid label.wide { grid-column: 1 / -1; } +.form-grid label.check { flex-direction: row; align-items: center; color: var(--fg); } +.form-grid .unit { display: flex; align-items: center; gap: 6px; color: var(--fg); } +.form-grid .unit input { width: 90px; } +input, select, textarea { font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); } +input[type=checkbox] { width: auto; margin-right: 6px; } +textarea { resize: vertical; } + +/* dataset management (ZFS page) */ +details.newds, details.destroy { margin-top: 8px; } +details.newds summary, details.destroy summary { cursor: pointer; } +details.newds summary { color: var(--accent); } +.ds-form { margin-top: 10px; } +.ds-form button { margin-top: 4px; } +.ds-destroy { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 6px; } +button.danger { border-color: var(--crit); color: var(--crit); } + +/* shares page */ +.card h3 { margin: 18px 0 8px; font-size: 14px; font-weight: 600; } +.nowrap { white-space: nowrap; } +.form-grid .field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; } +.form-grid .field-name { color: var(--muted); } +.checks { display: flex; flex-wrap: wrap; gap: 6px 16px; } +.checks .check-item { display: inline-flex; align-items: center; gap: 4px; color: var(--fg); font-size: 13px; } diff --git a/internal/web/static/app.js b/internal/web/static/app.js new file mode 100644 index 0000000..3713b2a --- /dev/null +++ b/internal/web/static/app.js @@ -0,0 +1,63 @@ +// Auto-refresh: re-fetch the current page's content block every few seconds +// and swap it in. Rendering stays server-side; this keeps the UI live without +// duplicating templates in JS. +(function () { + var page = document.body.dataset.page; + var interval = { dashboard: 5000, zfs: 15000, alerts: 15000, disk: 30000 }[page]; + if (!interval) return; + var main = document.getElementById('content'); + var dot = document.getElementById('refresh-state'); + var timer = null; + + // A refresh replaces the whole content block, which would wipe a form the + // user is typing into: hold off while a form is focused or has been edited. + main.addEventListener('input', function (e) { + var f = e.target.closest('form'); + if (f) f.classList.add('dirty'); + }); + // The ✕ / "show again" forms are single buttons, not something being typed + // into, so they never hold a refresh back. + function editing() { + if (main.querySelector('form.dirty:not(.x-form)')) return true; + var a = document.activeElement; + return !!(a && a.closest && a.closest('#content form:not(.x-form)')); + } + + function tick() { + if (document.hidden || editing()) { dot.className = 'refresh paused'; return; } + var open = [].slice.call(main.querySelectorAll('details[open]')).map(function (d) { return d.className; }); + var url = new URL(location.href); + url.searchParams.set('partial', '1'); + url.searchParams.delete('msg'); + var n = null; + fetch(url, { cache: 'no-store' }).then(function (r) { + if (!r.ok) throw new Error(r.status); + n = r.headers.get('X-Alerts-Open'); + return r.text(); + }).then(function (html) { + main.innerHTML = html; + open.forEach(function (cls) { + var d = main.querySelector('details.' + cls.split(' ').join('.')); + if (d) d.open = true; + }); + var nav = document.querySelector('nav a[href="/alerts"]'); + if (nav && n !== null) nav.innerHTML = 'Alerts' + (n !== '0' ? ' ' + n + '' : ''); + dot.className = 'refresh'; + }).catch(function () { dot.className = 'refresh stale'; }); + } + + // ✕ / "show again" forms: post in place and refresh the content block + document.addEventListener('submit', function (ev) { + var f = ev.target; + if (!f.classList.contains('x-form')) return; + ev.preventDefault(); + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); + fetch(f.action, { method: 'POST', headers: { 'X-Requested-With': 'fetch' } }) + .then(function (r) { if (!r.ok) throw new Error(r.status); tick(); }) + .catch(function () { f.submit(); }); + }); + + function start() { if (!timer) timer = setInterval(tick, interval); } + document.addEventListener('visibilitychange', function () { if (!document.hidden) tick(); }); + start(); +})(); diff --git a/internal/web/templates/alerts.html b/internal/web/templates/alerts.html new file mode 100644 index 0000000..07b2484 --- /dev/null +++ b/internal/web/templates/alerts.html @@ -0,0 +1,34 @@ +{{define "content"}} +{{template "flash" .}} +{{with .Data}} +{{if .LastMailErr}}
Last mail delivery failed: {{.LastMailErr}}
{{end}} +
+

Alert log {{if .All}}everything{{else}}open + resolved in the last 7 days{{end}}

+ {{if not .List}}

Nothing has happened yet.

{{end}} + {{range .List}} +
+ {{if .ResolvedAt}} +
+ {{else if not .Acked}} +
+ {{end}} +
+ {{if .ResolvedAt}}resolved{{else if .Acked}}acked{{else}}{{.Level}}{{end}} + {{.Title}} +
+
{{.Detail}}
+
+ opened {{dt .OpenedAt}} + {{if .ResolvedAt}} · resolved {{dtp .ResolvedAt}}{{end}} + {{if .Acked}} · acknowledged {{dtp .AckedAt}} ·
{{end}} + {{if .LastNotifiedAt}} · mailed {{dtp .LastNotifiedAt}}{{else if not .ResolvedAt}} · not mailed{{end}} + · {{.Key}} +
+
+ {{end}} + {{if .All}}

Hide resolved alerts older than 7 days

+ {{else if .Hidden}}

Show all ({{.Hidden}} older resolved hidden)

{{end}} +

× on an open alert acknowledges it: it leaves the dashboard and stops e-mail reminders until the problem changes (higher level, grown counters) or resolves by itself. × on a resolved alert removes it from the log; resolved alerts older than 90 days are removed automatically.

+
+{{end}} +{{end}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html new file mode 100644 index 0000000..3080ec7 --- /dev/null +++ b/internal/web/templates/dashboard.html @@ -0,0 +1,117 @@ +{{define "content"}} +{{template "flash" .}} +{{with .Data}} +{{template "alertlist" .S.Alerts}} + +
+
+

CPU {{.S.CPU.Cores}} cores

+
{{f0 .S.CPU.UsagePct}}%
+
load {{f1 (index .S.CPU.Load 0)}} / {{f1 (index .S.CPU.Load 1)}} / {{f1 (index .S.CPU.Load 2)}}
+ {{.CPUSpark}} +
last 24 h
+
+ +
+

Memory {{bytes .S.Mem.Total}}

+
{{f0 .MemPct}}%
+
+ + {{if .S.Mem.ARCSize}}{{end}} +
+
used {{bytes .S.Mem.Used}}{{if .S.Mem.ARCSize}} · ARC {{bytes .S.Mem.ARCSize}} / {{bytes .S.Mem.ARCMax}}{{end}}
+ {{if .S.Mem.SwapTotal}}
swap {{bytes .S.Mem.SwapUsed}} / {{bytes .S.Mem.SwapTotal}} ({{f0 .SwapPct}}%)
{{end}} + {{.MemSpark}} +
last 24 h
+
+ +
+

Temperature

+ {{if .S.HasCPUTemp}}
{{f0 .S.CPUTemp}}°C
CPU
{{else}}
—
no CPU sensor
{{end}} + {{.TempSpark}} +
+ {{len .S.Temps}} sensors + + {{range .S.Temps}}{{end}} +
{{.Chip}}{{.Label}}{{f0 .C}}°C
+
+
+ +
+

System

+
+
host {{.S.Host}}
+
root fs {{bytes .S.RootFS.Free}} free of {{bytes .S.RootFS.Total}}
+
SMART check {{ago .S.LastSmartCheck}}{{if .S.SmartRunning}} running{{else}} · next {{dt .S.NextSmartCheck}}{{end}}
+
nasmon {{.S.Version}} · up {{dur .S.Uptime}}
+ {{if .S.LastMailErr}}
mail error: {{.S.LastMailErr}}
{{end}} +
+
+
+
+ +
+

Disks

+ {{if .S.DisksErr}}
{{.S.DisksErr}}
{{end}} +
+ + + {{range .S.Disks}}{{$sm := .Smart}} + + + + + + + {{if $sm}} + + + + + + {{else}} + + {{end}} + + {{else}}{{end}} + +
DeviceModelSerialSizeBusTempHealthPower-onSelf-testWarnings
{{.Name}}{{.Model}}{{.Serial}}{{bytes .Size}}{{.Tran}}{{if not .Rota}} ssd{{end}}{{if $sm.HasTemp}}{{$sm.TempC}}°C{{if .Standby}} zz{{end}}{{else}}—{{end}}{{if .Error}}n/a{{else if not $sm.Supported}}n/a{{else if $sm.Healthy}}PASSED{{else}}FAILED{{end}}{{if $sm.PowerOnHrs}}{{$sm.PowerOnHrs}} h{{else}}—{{end}}{{with $sm.LastTest}}{{if .InProgress}}{{.Remaining}}% left{{else}}{{.Status}}{{end}}{{else}}—{{end}} + {{range $sm.Attrs}}{{if and (or (eq .ID 5) (eq .ID 187) (eq .ID 188) (eq .ID 197) (eq .ID 198)) (gt .Raw 0)}}{{.ID}}: {{.Raw}} {{end}}{{if .WhenFailed}}{{.ID}} {{.WhenFailed}} {{end}}{{end}} + {{with $sm.NVMe}}{{if .CriticalWarning}}warn 0x{{printf "%02x" .CriticalWarning}} {{end}}{{if .MediaErrors}}media err {{.MediaErrors}} {{end}}{{.PercentageUsed}}% used{{end}} + —{{if .Standby}}standby{{else if .Error}}n/a{{else}}pending{{end}}
no disks found
+
+ +
+

ZFS pools details

+ {{if .S.PoolsErr}}
{{.S.PoolsErr}}
{{end}} +
+ + + {{range .S.Pools}} + + + + + + + + + + + + {{else}}{{end}} + +
PoolHealthUsedFreeSizeCapacityFragLast scrubErrors
{{.Name}}{{.Health}}{{bytes .Alloc}}{{bytes .Free}}{{bytes .Size}}
{{.CapPct}}%
{{.FragPct}}%{{if .Scrubbing}}scrubbing{{else if .LastScrub}}{{agop .LastScrub}}{{else}}never{{end}}{{if .Errors}}{{.Errors}}{{end}}
no pools
+
+ +
+

Datasets

+
+ + + {{range .S.Datasets}}{{end}} + +
DatasetMountpointUsedReferencedAvailable
{{.Name}}{{.Mountpoint}}{{bytes .Used}}{{bytes .Refer}}{{bytes .Avail}}
+
+{{end}} +{{end}} diff --git a/internal/web/templates/disk.html b/internal/web/templates/disk.html new file mode 100644 index 0000000..5d74a75 --- /dev/null +++ b/internal/web/templates/disk.html @@ -0,0 +1,80 @@ +{{define "content"}} +{{template "flash" .}} +{{with .Data}}{{$sm := .D.Smart}} +
+

{{.D.Model}} {{.D.Serial}}

+
+
device {{.D.Path}}
+ {{if .D.Size}}
size {{bytes .D.Size}}
{{end}} +
bus {{.D.Tran}}{{if .D.Rota}} (rotational){{else}} (ssd){{end}}
+ {{if $sm}} +
protocol {{$sm.Protocol}}
+
firmware {{$sm.Firmware}}
+
health {{if not $sm.Supported}}not supported{{else if $sm.Healthy}}PASSED{{else}}FAILED{{end}}
+
temperature {{if $sm.HasTemp}}{{$sm.TempC}}°C{{else}}—{{end}}{{if .D.Standby}} (standby, last read){{end}}
+
power-on {{hours $sm.PowerOnHrs}}
+
power cycles {{$sm.PowerCycles}}
+
last SMART read {{dt $sm.CheckedAt}}
+ {{with $sm.LastTest}}
self-test {{if .InProgress}}in progress, {{.Remaining}}% left{{else}}{{.Type}}: {{.Status}} at {{.Hours}} h{{end}}
{{end}} + {{if $sm.Error}}
{{$sm.Error}}
{{end}} + {{end}} +
+ {{if .Known}}
{{end}} +
+ +{{if $sm}} +{{with $sm.NVMe}} +
+

NVMe health

+
+
critical warning {{if .CriticalWarning}}0x{{printf "%02x" .CriticalWarning}}{{else}}none{{end}}
+
percentage used {{.PercentageUsed}}%
+
available spare {{.AvailableSpare}}% (threshold {{.SpareThreshold}}%)
+
media errors {{if .MediaErrors}}{{.MediaErrors}}{{else}}0{{end}}
+
error log entries {{.ErrLogEntries}}
+
unsafe shutdowns {{.UnsafeShutdowns}}
+
+
+{{end}} + +
+

Temperature, last 30 days {{len .History}} snapshots

+ {{.TempSpark}} +
+ +{{if $sm.Attrs}} +
+

SMART attributes

+
+ + + {{range $sm.Attrs}} + {{$crit := and (or (eq .ID 5) (eq .ID 187) (eq .ID 188) (eq .ID 197) (eq .ID 198)) (gt .Raw 0)}} + + + + + + + {{end}} + +
IDNameValueWorstThreshRawFailed
{{.ID}}{{.Name}}{{.Value}}{{.Worst}}{{.Thresh}}{{.RawString}}{{if .WhenFailed}}{{.WhenFailed}}{{end}}
+
+{{end}} + +{{if .History}} +
+

History

+
+ + + {{range .HistoryRev}}{{end}} + +
TimeTempHealthHours{{if $sm.NVMe}}Media err{{else}}Realloc (5){{end}}Pending (197)CRC (199)
{{dt (unix .T)}}{{.TempC}}°C{{if .Healthy}}ok{{else}}FAIL{{end}}{{.Hours}}{{.Realloc}}{{.Pending}}{{.CRC}}
+
+{{end}} +{{else}} +

No SMART data yet{{if .D.Standby}} — disk is in standby, waiting for it to wake up{{end}}.

+{{end}} +{{end}} +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html new file mode 100644 index 0000000..d243504 --- /dev/null +++ b/internal/web/templates/layout.html @@ -0,0 +1,38 @@ +{{define "layout"}} + + + + +{{.Title}} · nasmon + + + + +
+ nasmon + + +
+
{{template "content" .}}
+ + +{{end}} + +{{define "flash"}}{{if .Flash}}
{{.Flash}}
{{end}}{{if .Error}}
{{.Error}}
{{end}}{{end}} + +{{define "alertlist"}}{{if .}} +
+

Open alerts

+ {{range .}}
+
+
{{.Level}} {{.Title}}
+
{{.Detail}}
+
since {{dt .OpenedAt}}
+
{{end}} +
{{end}}{{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html new file mode 100644 index 0000000..2898a02 --- /dev/null +++ b/internal/web/templates/settings.html @@ -0,0 +1,76 @@ +{{define "content"}} +{{template "flash" .}} +{{with .Data}}{{$c := .C}} +
+ +
+

E-mail (SMTP)

+
+ + + + + + + + +
+

Gmail/Yandex/Mail.ru need an app password, not the account password. Yandex: smtp.yandex.ru 465 TLS; Gmail: smtp.gmail.com 587 STARTTLS.

+
+ +
+

Alerts

+
+ + + +
+
+ +
+

SMART polling

+
+ + +
+
+ +
+

Thresholds

+
+ + + + + + + +
+

0 disables a threshold. SMART health FAILED, reallocated/pending sectors, pool state and vdev errors always alert.

+
+ +
+

Scheduled self-tests

+
+ + + + +
+

Tests start only for disks that are awake. A long test on a large HDD takes many hours; disk stays usable.

+
+ +
+ + +
+
+{{end}} +{{end}} diff --git a/internal/web/templates/shares.html b/internal/web/templates/shares.html new file mode 100644 index 0000000..b1cea60 --- /dev/null +++ b/internal/web/templates/shares.html @@ -0,0 +1,141 @@ +{{define "content"}} +{{template "flash" .}} +{{with .Data}} +{{if .ApplyErr}}
Configuration saved but not applied: {{.ApplyErr}} +
{{end}} + +{{range .Mountpoints}} + +
+

SMB (Windows / macOS network shares) + {{if not .Svc.SMBInstalled}}not installed + {{else if .Svc.SMBActive}}running {{.Svc.SMBSessions}} session(s) + {{else}}stopped{{end}} +

+ {{if not .Svc.SMBInstalled}}

Samba is not installed — run sudo apt install samba wsdd on the NAS (install.sh does it). Shares can be defined now and are applied once Samba is there.

{{end}} + +
+ + + +
 
+
+ +

Shares

+
+ + + {{range .C.SMB.Shares}} + + + + + + + + + {{else}}{{end}} + +
NamePathAccessWhoConnect as
{{.Name}}{{if .Comment}} {{.Comment}}{{end}}{{if not .Browseable}} hidden{{end}}{{.Path}}{{if .ReadOnly}}read-only{{else}}read/write{{end}}{{if .Guest}}guest (no password){{else}}{{join .Users ", "}}{{end}}\\{{$.Data.Host}}\{{.Name}} + edit · +
+
No SMB shares yet.
+ +
+ {{if .Form.New}}New share{{else}}Edit share {{.Form.Orig}}{{end}} +
+ +
+ + + + + + + +
+ Allowed users (when guest access is off) + {{range .Users}}{{else}}no SMB users yet — add one below{{end}} +
+
+

Files are read and written as {{.Owner}} regardless of the SMB user, so containers and shares see the same ownership.

+ + {{if not .Form.New}}cancel{{end}} +
+
+ +

SMB users

+
+ + {{range .Users}}{{else}}{{end}} + +
{{.}} +
+
No SMB users. Guest shares work without users.
+
+ + {{range .Users}} + +
 
+
+

Windows: \\{{.Host}}\share in Explorer, or map a network drive. macOS: Finder → Go → Connect to Server → smb://{{.Host}}/share. + Note: Windows 10/11 Pro/Enterprise refuse guest shares by default (error 0x80070035 «network path not found») — use an SMB user with a password, or allow guest access on the PC (gpedit: Lanman Workstation → Enable insecure guest logons).

+
+ +
+

NFS (Linux / other NAS) + {{if not .Svc.NFSInstalled}}not installed + {{else if .Svc.NFSActive}}running + {{else}}stopped{{end}} +

+ {{if not .Svc.NFSInstalled}}

NFS server is not installed — run sudo apt install nfs-kernel-server on the NAS (install.sh does it).

{{end}} +
+ +
 
+
+ +
+ + + {{range .C.NFS.Exports}} + + + + + + + + + {{else}}{{end}} + +
PathClientsAccessSquashMount
{{.Path}}{{join .Clients ", "}}{{if .ReadOnly}}read-only{{else}}read/write{{end}}{{if .Async}} async{{end}}{{.Squash}}{{$.Data.Host}}:{{.Path}} + edit · +
+
No NFS exports yet.
+ +
+ {{if .NForm.New}}New export{{else}}Edit export {{.NForm.Orig}}{{end}} +
+ +
+ + + + + +
+

Nested datasets under the path are exported too (crossmnt). Clients: host names, IPs, networks like 192.168.101.0/24, or * for everyone.

+ + {{if not .NForm.New}}cancel{{end}} +
+
+

Linux: sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas; list exports with showmount -e {{.Host}}.

+
+{{end}} +{{end}} diff --git a/internal/web/templates/zfs.html b/internal/web/templates/zfs.html new file mode 100644 index 0000000..57ace10 --- /dev/null +++ b/internal/web/templates/zfs.html @@ -0,0 +1,82 @@ +{{define "content"}} +{{template "flash" .}} +{{with .Data}} +{{if .S.PoolsErr}}
{{.S.PoolsErr}}
{{end}} +{{$form := .Form}} +{{range .S.Pools}} +
+

{{.Name}} {{.Health}}

+
+
size {{bytes .Size}}
+
allocated {{bytes .Alloc}} ({{.CapPct}}%)
+
free {{bytes .Free}}
+
fragmentation {{.FragPct}}%
+
last scrub {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}
+
errors {{if .Errors}}{{.Errors}}{{else}}—{{end}}
+
+
+ {{if .Scan}}

scan: {{.Scan}}

{{end}} + {{if .Status}}

status: {{.Status}}

{{end}} + {{if .Action}}

action: {{.Action}}

{{end}} +
+ + + {{range .Vdevs}} + + + + + + + {{end}} + +
vdevStateReadWriteCksum
{{.Name}}{{if .State}}{{.State}}{{end}}{{.Read}}{{.Write}}{{.Cksum}}{{.Note}}
+
+ New dataset in {{.Name}} +
+ +
+ + + + +
+

Creates {{.Name}}/<name> (parents are created too), mounts it at /{{.Name}}/<name> and chowns the mountpoint to the owner.

+ +
+
+
+{{else}}

No ZFS pools found.

{{end}} + +
+

Datasets

+
+ + + {{range .Datasets}} + + + + {{end}} + +
DatasetMountpointUsedReferencedAvailable
{{.Name}}{{.Mountpoint}}{{bytes .Used}}{{bytes .Refer}}{{bytes .Avail}}{{if hasPrefix .Mountpoint "/"}}share… {{end}}{{if .CanDestroy}} +
+ destroy… +
+ + + +
+
+ {{end}}
+

Only empty datasets (no data, no children, no snapshots) can be destroyed here.

+
+{{end}} +{{end}} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..88f82d5 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,406 @@ +package web + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "nasmon/internal/alert" + "nasmon/internal/collect" + "nasmon/internal/config" + "nasmon/internal/history" + "nasmon/internal/sched" + "nasmon/internal/shares" + "nasmon/internal/store" + "nasmon/internal/zfsadm" +) + +func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) { + t.Helper() + dir := t.TempDir() + cfg, err := config.Load(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(dir, "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + ring := history.New(100) + for i := 0; i < 50; i++ { + ring.Add(history.Sample{T: time.Now().Add(-time.Duration(50-i) * time.Minute).Unix(), CPU: float64(i), MemUsed: 1 << 30, CPUTemp: 40 + float64(i%10)}) + } + am := &alert.Manager{Store: st, Config: cfg} + sc := sched.New(cfg, st, ring, am, "test") + now := time.Now() + old := now.Add(-100 * 24 * time.Hour) + hdd := &collect.Smart{Device: "/dev/sda", Protocol: "ATA", Model: "WDC WD80EFZZ", Serial: "WD-AAA", Firmware: "81.00A81", + Supported: true, Healthy: true, HasTemp: true, TempC: 36, PowerOnHrs: 7541, PowerCycles: 21, CheckedAt: now, + Attrs: []collect.SmartAttr{{ID: 5, Name: "Reallocated_Sector_Ct", Value: 200, Worst: 200, Thresh: 140, Raw: 3, RawString: "3"}, + {ID: 194, Name: "Temperature_Celsius", Value: 116, Raw: 36, RawString: "36"}}, + LastTest: &collect.SelfTest{Type: "Extended offline", Status: "Completed without error", Passed: true, Hours: 7000}} + nvme := &collect.Smart{Device: "/dev/nvme0n1", Protocol: "NVMe", Model: "Samsung 980", Serial: "S64", Supported: true, Healthy: true, + HasTemp: true, TempC: 41, PowerOnHrs: 1234, CheckedAt: now, + NVMe: &collect.NVMeHealth{AvailableSpare: 100, SpareThreshold: 10, PercentageUsed: 3}, + LastTest: &collect.SelfTest{InProgress: true, Remaining: 40, Passed: true}} + _ = st.AddSmart(*hdd) + _ = st.TouchDisk("WD-AAA", "WDC WD80EFZZ", "/dev/sda", now) + a, _ := st.OpenAlert("zfs:tank:health", alert.LevelCrit, "Pool tank is DEGRADED", "State: DEGRADED\nAction: replace", now) + sc.SetStatus(sched.Status{ + UpdatedAt: now, + CPU: collect.CPU{UsagePct: 12.5, Cores: 4, Load: [3]float64{0.5, 0.4, 0.3}}, + Mem: collect.Memory{Total: 16 << 30, Available: 8 << 30, Used: 8 << 30, ARCSize: 4 << 30, ARCMax: 8 << 30, SwapTotal: 1 << 30, SwapUsed: 100 << 20}, + Temps: []collect.Temp{{Chip: "coretemp", Label: "Package id 0", C: 45}}, + CPUTemp: 45, HasCPUTemp: true, + Disks: []sched.DiskView{ + {Disk: collect.Disk{Name: "sda", Path: "/dev/sda", Model: "WDC WD80EFZZ", Serial: "WD-AAA", Size: 8 << 40, Rota: true, Tran: "sata"}, Smart: hdd}, + {Disk: collect.Disk{Name: "nvme0n1", Path: "/dev/nvme0n1", Model: "Samsung 980", Serial: "S64", Size: 500 << 30, Tran: "nvme"}, Smart: nvme}, + {Disk: collect.Disk{Name: "sdb", Path: "/dev/sdb", Model: "Sleepy", Serial: "ZZZ", Size: 1 << 40, Rota: true, Tran: "sata"}, Standby: true}, + {Disk: collect.Disk{Name: "sdc", Path: "/dev/sdc", Model: "USB", Serial: "U1", Size: 1 << 40, Tran: "usb"}, Error: "open failed"}, + }, + Pools: []collect.Pool{ + {Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old, + Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors", + Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}}, + {Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"}, + }, + Datasets: []collect.Dataset{ + {Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank"}, + {Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"}, + {Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"}, + {Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"}, + {Name: "tank/parent/child", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent/child"}, + }, + RootFS: collect.FSUsage{Path: "/", Total: 100 << 30, Free: 40 << 30}, + Alerts: []store.Alert{a}, + LastSmartCheck: now.Add(-10 * time.Minute), NextSmartCheck: now.Add(20 * time.Minute), + LastMailErr: "auth: 535", + }) + shs, err := shares.Load(filepath.Join(dir, "shares.json")) + if err != nil { + t.Fatal(err) + } + srv := New(cfg, st, ring, sc, shs) + srv.dirExists = func(p string) bool { return strings.HasPrefix(p, "/tank") || strings.HasPrefix(p, "/tmp_pool") } + return srv, sc, st +} + +func TestPagesRender(t *testing.T) { + srv, _, _ := newTestServer(t) + for _, path := range []string{"/", "/?partial=1", "/zfs", "/alerts", "/settings", "/shares", "/shares?path=/tank/data", "/disks/WD-AAA", "/disks/S64", "/disks/ZZZ", "/disks/U1", "/api/status", "/api/history", "/api/disks/WD-AAA/history", "/static/app.css"} { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) + if rec.Code != 200 { + t.Errorf("%s: status %d", path, rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "") || strings.Contains(body, "{{") { + t.Errorf("%s: template leak in output", path) + } + if len(body) < 100 { + t.Errorf("%s: suspiciously short body: %q", path, body) + } + } + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/disks/NOPE", nil)) + if rec.Code != 404 { + t.Errorf("unknown disk: status %d", rec.Code) + } +} + +func TestDashboardContent(t *testing.T) { + srv, _, _ := newTestServer(t) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + body := rec.Body.String() + for _, want := range []string{"Pool tank is DEGRADED", "WD-AAA", "PASSED", "standby", "40% left", "DEGRADED", "scrubbing", "mail error", "8.0 TiB", "ARC"} { + if !strings.Contains(body, want) { + t.Errorf("dashboard missing %q", want) + } + } + if rec.Header().Get("X-Alerts-Open") != "1" { + t.Errorf("X-Alerts-Open = %q", rec.Header().Get("X-Alerts-Open")) + } +} + +func TestSettingsRoundTrip(t *testing.T) { + srv, _, _ := newTestServer(t) + var sent []string + srv.send = func(c config.SMTP, subject, body string) error { + sent = append(sent, subject+"|"+strings.Join(c.To, ",")) + return nil + } + form := url.Values{ + "smtp_host": {"smtp.example.com"}, "smtp_port": {"465"}, "smtp_security": {"tls"}, + "smtp_user": {"u"}, "smtp_pass": {"secret"}, "smtp_from": {"nas@example.com"}, + "smtp_to": {"a@example.com\nb@example.com"}, "smtp_prefix": {"[nas]"}, + "alerts_enabled": {"1"}, "remind_hours": {"12"}, "smart_interval": {"15"}, + "th_disk_temp": {"48"}, "st_enabled": {"1"}, "st_hour": {"4"}, + "action": {"test"}, + } + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "test mail sent") { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()[:200]) + } + if len(sent) != 1 || !strings.Contains(sent[0], "a@example.com,b@example.com") { + t.Errorf("sent = %v", sent) + } + c := srv.cfg.Get() + if c.SMTP.Host != "smtp.example.com" || c.SMTP.Port != 465 || c.SMTP.Security != "tls" || c.SMTP.Pass != "secret" { + t.Errorf("smtp = %+v", c.SMTP) + } + if c.RemindHours != 12 || c.SmartIntervalMin != 15 || c.Thresholds.DiskTempC != 48 || !c.SelfTests.Enabled || c.SelfTests.Hour != 4 { + t.Errorf("settings = %+v", c) + } + if c.NotifyResolved || c.SmartSkipStandby { + t.Errorf("unchecked boxes must be false: %+v", c) + } + // password must not be echoed + if strings.Contains(rec.Body.String(), "secret") { + t.Errorf("password leaked into the page") + } + // second save without password keeps the old one + form.Del("smtp_pass") + form.Set("action", "save") + rec = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.ServeHTTP(rec, req) + if srv.cfg.Get().SMTP.Pass != "secret" { + t.Errorf("empty password field must keep the stored one") + } + // invalid value is rejected + form.Set("smtp_port", "99999") + rec = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.ServeHTTP(rec, req) + if !strings.Contains(rec.Body.String(), "Not saved") { + t.Errorf("invalid port accepted") + } +} + +func TestSmartRunRedirect(t *testing.T) { + srv, _, _ := newTestServer(t) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("POST", "/smart/run", nil)) + if rec.Code != http.StatusSeeOther { + t.Errorf("status %d", rec.Code) + } +} + +// fakeRunner records host commands and answers "zfs get"/"zfs list" queries. +type fakeRunner struct { + calls []string + fail string // command prefix that fails +} + +func (f *fakeRunner) run(_ context.Context, name string, args ...string) (string, error) { + c := name + " " + strings.Join(args, " ") + f.calls = append(f.calls, c) + if f.fail != "" && strings.HasPrefix(c, f.fail) { + return "cannot do that", errors.New("cannot do that") + } + switch { + case strings.HasPrefix(c, "zfs get"): + return "/" + args[len(args)-1], nil + case strings.HasPrefix(c, "zfs list -Hp"): + return args[len(args)-1] + " 98304", nil + } + return "", nil +} + +func postForm(srv *Server, path string, form url.Values) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.ServeHTTP(rec, req) + return rec +} + +func TestZfsPageControls(t *testing.T) { + srv, _, _ := newTestServer(t) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil)) + body := rec.Body.String() + for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm"} { + if !strings.Contains(body, want) { + t.Errorf("zfs page missing %q", want) + } + } + for _, no := range []string{"type tank to confirm", "type tank/data to confirm", "type tank/parent to confirm"} { + if strings.Contains(body, no) { + t.Errorf("zfs page offers destroy for a non-empty/parent/root dataset: %q", no) + } + } + if !strings.Contains(body, "type tank/parent/child to confirm") { + t.Errorf("leaf child should be destroyable") + } +} + +func TestZfsCreate(t *testing.T) { + srv, _, _ := newTestServer(t) + f := &fakeRunner{} + srv.admin = zfsadm.New(f.run) + + // rejected input never reaches the runner (checked first: a successful + // create refreshes the status from the real host, which has no ZFS here) + bad := []url.Values{ + {"pool": {"nope"}, "name": {"x"}}, // unknown pool + {"pool": {"tank"}, "name": {"../etc"}}, // bad name + {"pool": {"tank"}, "name": {"data"}}, // exists + {"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota + {"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner + {"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}}, + } + for _, form := range bad { + f.calls = nil + rec := postForm(srv, "/zfs/create", form) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not created") { + t.Errorf("%v: status %d, no error shown", form, rec.Code) + } + if len(f.calls) != 0 { + t.Errorf("%v: commands ran: %v", form, f.calls) + } + if strings.Contains(rec.Body.String(), "") { + t.Errorf("%v: template leak", form) + } + } + + rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "quota": {"100g"}}) + if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") { + t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) + } + want := "zfs create -p -o compression=lz4 -o quota=100G tank/media/video" + if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" { + t.Errorf("calls = %v", f.calls) + } + + // zfs failure is shown and the form keeps its values + f = &fakeRunner{fail: "zfs create"} + srv.admin = zfsadm.New(f.run) + rec = postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"x"}, "quota": {"5G"}}) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") || !strings.Contains(rec.Body.String(), `value="5G"`) { + t.Errorf("failure not reported: %d %s", rec.Code, rec.Body.String()[:300]) + } +} + +func TestZfsDestroy(t *testing.T) { + srv, _, _ := newTestServer(t) + f := &fakeRunner{} + srv.admin = zfsadm.New(f.run) + + bad := []url.Values{ + {"name": {"tank/empty"}, "confirm": {"tank/emp"}}, // wrong confirmation + {"name": {"tank/data"}, "confirm": {"tank/data"}}, // not empty + {"name": {"tank/parent"}, "confirm": {"tank/parent"}}, // has child + {"name": {"tank"}, "confirm": {"tank"}}, // pool root + {"name": {"tank/none"}, "confirm": {"tank/none"}}, // unknown + } + for _, form := range bad { + f.calls = nil + rec := postForm(srv, "/zfs/destroy", form) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not destroyed") { + t.Errorf("%v: status %d", form, rec.Code) + } + if len(f.calls) != 0 { + t.Errorf("%v: commands ran: %v", form, f.calls) + } + } + + rec := postForm(srv, "/zfs/destroy", url.Values{"name": {"tank/empty"}, "confirm": {"tank/empty"}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if len(f.calls) == 0 || f.calls[len(f.calls)-1] != "zfs destroy tank/empty" { + t.Errorf("calls = %v", f.calls) + } +} + +func get(srv http.Handler, path string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) + return rec +} + +func post(srv http.Handler, path string, hdr map[string]string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", path, nil) + for k, v := range hdr { + req.Header.Set(k, v) + } + srv.ServeHTTP(rec, req) + return rec +} + +func TestAlertAckAndDelete(t *testing.T) { + srv, _, st := newTestServer(t) + open, _ := st.OpenAlerts() + id := open[0].ID + idS := strconv.FormatInt(id, 10) + + // dashboard shows the open alert with an ack control + body := get(srv, "/").Body.String() + if !strings.Contains(body, "/alerts/"+idS+"/ack") { + t.Fatalf("dashboard lacks ack form") + } + + // ack via fetch: 204, gone from dashboard and badge + rec := post(srv, "/alerts/"+idS+"/ack", map[string]string{"X-Requested-With": "fetch"}) + if rec.Code != http.StatusNoContent { + t.Fatalf("ack status %d", rec.Code) + } + rec = get(srv, "/") + if strings.Contains(rec.Body.String(), "Pool tank is DEGRADED") || rec.Header().Get("X-Alerts-Open") != "0" { + t.Errorf("acked alert still on dashboard / badge=%q", rec.Header().Get("X-Alerts-Open")) + } + // /alerts shows it as acked with "show again" + body = get(srv, "/alerts").Body.String() + if !strings.Contains(body, ">acked<") || !strings.Contains(body, "/alerts/"+idS+"/unack") { + t.Errorf("alerts page missing acked state") + } + // delete refused for an open alert + if rec := post(srv, "/alerts/"+idS+"/delete", nil); rec.Code != http.StatusConflict { + t.Errorf("delete of open alert: %d", rec.Code) + } + // unack via a plain form post redirects back to the referer with a message + rec = post(srv, "/alerts/"+idS+"/unack", map[string]string{"Referer": "/alerts?all=1"}) + if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "all=1") || !strings.Contains(rec.Header().Get("Location"), "msg=") { + t.Errorf("unack redirect: %d %s", rec.Code, rec.Header().Get("Location")) + } + if rec := get(srv, "/"); rec.Header().Get("X-Alerts-Open") != "1" { + t.Errorf("badge after unack = %q", rec.Header().Get("X-Alerts-Open")) + } + + // resolved long ago: hidden by default, visible with ?all=1, deletable + oldA, _ := st.OpenAlert("k:old", "warn", "Ancient problem", "d", time.Now().Add(-40*24*time.Hour)) + _ = st.ResolveAlert(oldA.ID, time.Now().Add(-39*24*time.Hour)) + body = get(srv, "/alerts").Body.String() + if strings.Contains(body, "Ancient problem") || !strings.Contains(body, "Show all (1 older resolved hidden)") { + t.Errorf("old resolved alert not hidden by default") + } + body = get(srv, "/alerts?all=1").Body.String() + if !strings.Contains(body, "Ancient problem") || !strings.Contains(body, "/alerts/"+strconv.FormatInt(oldA.ID, 10)+"/delete") { + t.Errorf("old resolved alert missing from ?all=1") + } + if rec := post(srv, "/alerts/"+strconv.FormatInt(oldA.ID, 10)+"/delete", map[string]string{"X-Requested-With": "fetch"}); rec.Code != http.StatusNoContent { + t.Errorf("delete: %d", rec.Code) + } + if strings.Contains(get(srv, "/alerts?all=1").Body.String(), "Ancient problem") { + t.Errorf("deleted alert still listed") + } + if rec := post(srv, "/alerts/999/ack", nil); rec.Code != 404 { + t.Errorf("unknown alert: %d", rec.Code) + } +} diff --git a/internal/zfsadm/zfsadm.go b/internal/zfsadm/zfsadm.go new file mode 100644 index 0000000..07c5f51 --- /dev/null +++ b/internal/zfsadm/zfsadm.go @@ -0,0 +1,214 @@ +// Package zfsadm performs the few mutating ZFS operations the UI offers +// (create / destroy datasets). Commands go through a Runner so they can be +// executed on the host via systemd-run: nasmon runs in a sandboxed mount +// namespace (ProtectSystem=strict), where a plain "zfs create" would mount the +// new dataset only inside the service and leave the host without it. +package zfsadm + +import ( + "bufio" + "context" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "nasmon/internal/hostrun" +) + +// Runner executes a command and returns its combined output; err is non-nil on +// a non-zero exit or when the command could not be started. +type Runner func(ctx context.Context, name string, args ...string) (out string, err error) + +// NoInput adapts a hostrun.Runner (which takes stdin) to the Runner used here. +func NoInput(r hostrun.Runner) Runner { + return func(ctx context.Context, name string, args ...string) (string, error) { + return r(ctx, "", name, args...) + } +} + +// DirectRunner runs the command in-process (development, tests, non-systemd hosts). +var DirectRunner = NoInput(hostrun.Direct) + +// HostRunner runs the command as a transient systemd unit, i.e. in the host's +// mount namespace and outside this service's sandbox. +var HostRunner = NoInput(hostrun.Host) + +// AutoRunner picks HostRunner when systemd is managing the system and +// systemd-run is available, DirectRunner otherwise. +func AutoRunner() Runner { return NoInput(hostrun.Auto()) } + +// Admin runs dataset operations through Run. +type Admin struct { + Run Runner + Timeout time.Duration +} + +func New(run Runner) *Admin { return &Admin{Run: run, Timeout: 60 * time.Second} } + +func (a *Admin) ctx() (context.Context, context.CancelFunc) { + t := a.Timeout + if t <= 0 { + t = 60 * time.Second + } + return context.WithTimeout(context.Background(), t) +} + +// CreateOpts are the user-visible knobs of "zfs create". +type CreateOpts struct { + Compression string // "", lz4, zstd, off ("" = inherit) + Quota string // "", or a size like 100G / 1.5T + Owner string // uid:gid applied to the mountpoint; "" = leave root +} + +// Create creates dataset full (pool/name[/sub]) and chowns its mountpoint. +func (a *Admin) Create(full string, o CreateOpts) error { + if err := ValidateFullName(full); err != nil { + return err + } + if err := ValidateCompression(o.Compression); err != nil { + return err + } + if err := ValidateSize(o.Quota); err != nil { + return err + } + if err := ValidateOwner(o.Owner); err != nil { + return err + } + args := []string{"create", "-p"} + if o.Compression != "" { + args = append(args, "-o", "compression="+o.Compression) + } + if o.Quota != "" { + args = append(args, "-o", "quota="+o.Quota) + } + args = append(args, full) + ctx, cancel := a.ctx() + defer cancel() + if _, err := a.Run(ctx, "zfs", args...); err != nil { + return err + } + if o.Owner == "" { + return nil + } + mp, err := a.Run(ctx, "zfs", "get", "-H", "-o", "value", "mountpoint", full) + if err != nil { + return fmt.Errorf("created, but mountpoint lookup failed: %w", err) + } + if !strings.HasPrefix(mp, "/") { + return fmt.Errorf("created, but dataset is not mounted (mountpoint=%s); chown skipped", mp) + } + if _, err := a.Run(ctx, "chown", o.Owner, mp); err != nil { + return fmt.Errorf("created, but chown failed: %w", err) + } + return nil +} + +// EmptyRefer is the upper bound of "referenced" bytes for a dataset we still +// consider empty (a fresh dataset refers ~96-128 KiB of metadata). +const EmptyRefer = 1 << 20 + +// DestroyEmpty destroys full only if, on the live system, it has no children, +// no snapshots and refers less than EmptyRefer bytes. +func (a *Admin) DestroyEmpty(full string) error { + if err := ValidateFullName(full); err != nil { + return err + } + if !strings.Contains(full, "/") { + return errors.New("refusing to destroy a pool root dataset") + } + ctx, cancel := a.ctx() + defer cancel() + out, err := a.Run(ctx, "zfs", "list", "-Hp", "-r", "-t", "filesystem", "-o", "name,refer", full) + if err != nil { + return err + } + sc := bufio.NewScanner(strings.NewReader(out)) + rows := 0 + var refer uint64 + for sc.Scan() { + f := strings.Split(sc.Text(), "\t") + if len(f) < 2 { + continue + } + rows++ + if f[0] == full { + refer, _ = strconv.ParseUint(f[1], 10, 64) + } + } + if rows != 1 { + return fmt.Errorf("%s has child datasets", full) + } + if refer >= EmptyRefer { + return fmt.Errorf("%s is not empty (%d bytes referenced)", full, refer) + } + snaps, err := a.Run(ctx, "zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", full) + if err != nil { + return err + } + if strings.TrimSpace(snaps) != "" { + return fmt.Errorf("%s has snapshots", full) + } + _, err = a.Run(ctx, "zfs", "destroy", full) + return err +} + +// ---- validation (pure) + +var ( + reComponent = regexp.MustCompile(`^[A-Za-z0-9_.:-]+$`) + reSize = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?[KMGTPkmgtp]?$`) + reOwner = regexp.MustCompile(`^[0-9]+:[0-9]+$`) +) + +// ValidateRelName checks a dataset name relative to its pool, e.g. "media/video". +func ValidateRelName(s string) error { + if s == "" { + return errors.New("name is required") + } + if len(s) > 200 { + return errors.New("name is too long") + } + for _, c := range strings.Split(s, "/") { + switch { + case c == "": + return errors.New("name must not start or end with / or contain //") + case c == "." || c == "..": + return errors.New("name must not contain . or .. components") + case strings.HasPrefix(c, "-"): + return errors.New("name components must not start with -") + case !reComponent.MatchString(c): + return errors.New("name may only contain letters, digits, _ . : - and /") + } + } + return nil +} + +// ValidateFullName checks "pool/name..." (or a bare pool). +func ValidateFullName(s string) error { + return ValidateRelName(s) +} + +func ValidateSize(s string) error { + if s == "" || reSize.MatchString(s) { + return nil + } + return errors.New("size must look like 100G, 1.5T or 512M") +} + +func ValidateCompression(s string) error { + switch s { + case "", "lz4", "zstd", "off": + return nil + } + return errors.New("compression must be inherit, lz4, zstd or off") +} + +func ValidateOwner(s string) error { + if s == "" || reOwner.MatchString(s) { + return nil + } + return errors.New("owner must be numeric uid:gid, e.g. 1000:1000") +} diff --git a/internal/zfsadm/zfsadm_test.go b/internal/zfsadm/zfsadm_test.go new file mode 100644 index 0000000..5dc46e6 --- /dev/null +++ b/internal/zfsadm/zfsadm_test.go @@ -0,0 +1,153 @@ +package zfsadm + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestValidators(t *testing.T) { + for _, s := range []string{"media", "media/video", "a.b_c:d-e", "x/y/z"} { + if err := ValidateRelName(s); err != nil { + t.Errorf("%q rejected: %v", s, err) + } + } + for _, s := range []string{"", "/media", "media/", "a//b", "..", "a/../b", ".", "-x", "a/-b", "a b", "a;b", "a/b\n", strings.Repeat("x", 201)} { + if ValidateRelName(s) == nil { + t.Errorf("%q accepted", s) + } + } + for _, s := range []string{"", "100G", "1.5T", "512M", "10k", "42"} { + if ValidateSize(s) != nil { + t.Errorf("size %q rejected", s) + } + } + for _, s := range []string{"10 G", "10GB", "G", "-1G", "1e3"} { + if ValidateSize(s) == nil { + t.Errorf("size %q accepted", s) + } + } + if ValidateCompression("gzip") == nil || ValidateCompression("lz4") != nil || ValidateCompression("") != nil { + t.Error("compression validation") + } + if ValidateOwner("ansible") == nil || ValidateOwner("1000:1000") != nil || ValidateOwner("") != nil { + t.Error("owner validation") + } +} + +// fake records calls and answers by command prefix. +type fake struct { + calls []string + reply map[string]string + fail map[string]string +} + +func (f *fake) run(_ context.Context, name string, args ...string) (string, error) { + c := name + " " + strings.Join(args, " ") + f.calls = append(f.calls, c) + for k, v := range f.fail { + if strings.HasPrefix(c, k) { + return v, errors.New(v) + } + } + for k, v := range f.reply { + if strings.HasPrefix(c, k) { + return v, nil + } + } + return "", nil +} + +func TestCreate(t *testing.T) { + f := &fake{reply: map[string]string{"zfs get": "/tank/media"}} + a := New(f.run) + if err := a.Create("tank/media", CreateOpts{Compression: "lz4", Quota: "100G", Owner: "1000:1000"}); err != nil { + t.Fatal(err) + } + want := []string{ + "zfs create -p -o compression=lz4 -o quota=100G tank/media", + "zfs get -H -o value mountpoint tank/media", + "chown 1000:1000 /tank/media", + } + if strings.Join(f.calls, "\n") != strings.Join(want, "\n") { + t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n")) + } + + // no owner, inherit everything + f = &fake{} + a = New(f.run) + if err := a.Create("tank/x", CreateOpts{}); err != nil { + t.Fatal(err) + } + if len(f.calls) != 1 || f.calls[0] != "zfs create -p tank/x" { + t.Errorf("calls = %v", f.calls) + } + + // invalid input never reaches the runner + f = &fake{} + a = New(f.run) + if err := a.Create("tank/../x", CreateOpts{}); err == nil || len(f.calls) != 0 { + t.Errorf("bad name: err=%v calls=%v", err, f.calls) + } + if err := a.Create("tank/x", CreateOpts{Quota: "lots"}); err == nil || len(f.calls) != 0 { + t.Errorf("bad quota: err=%v calls=%v", err, f.calls) + } + + // zfs error is surfaced verbatim + f = &fake{fail: map[string]string{"zfs create": "cannot create 'tank/x': out of space"}} + a = New(f.run) + if err := a.Create("tank/x", CreateOpts{}); err == nil || !strings.Contains(err.Error(), "out of space") { + t.Errorf("err = %v", err) + } + + // unmounted dataset: created but chown skipped, reported + f = &fake{reply: map[string]string{"zfs get": "none"}} + a = New(f.run) + err := a.Create("tank/x", CreateOpts{Owner: "1000:1000"}) + if err == nil || !strings.Contains(err.Error(), "chown skipped") { + t.Errorf("err = %v", err) + } + for _, c := range f.calls { + if strings.HasPrefix(c, "chown") { + t.Errorf("chown must not run: %v", f.calls) + } + } +} + +func TestDestroyEmpty(t *testing.T) { + ok := map[string]string{"zfs list -Hp -r -t filesystem": "tank/x\t98304", "zfs list -H -t snapshot": ""} + f := &fake{reply: ok} + a := New(f.run) + if err := a.DestroyEmpty("tank/x"); err != nil { + t.Fatal(err) + } + if f.calls[len(f.calls)-1] != "zfs destroy tank/x" { + t.Errorf("calls = %v", f.calls) + } + + cases := map[string]map[string]string{ + "children": {"zfs list -Hp -r -t filesystem": "tank/x\t98304\ntank/x/y\t98304", "zfs list -H -t snapshot": ""}, + "not empty": {"zfs list -Hp -r -t filesystem": "tank/x\t5000000", "zfs list -H -t snapshot": ""}, + "snapshots": {"zfs list -Hp -r -t filesystem": "tank/x\t98304", "zfs list -H -t snapshot": "tank/x@s1"}, + } + for name, reply := range cases { + f := &fake{reply: reply} + a := New(f.run) + err := a.DestroyEmpty("tank/x") + if err == nil { + t.Errorf("%s: destroyed", name) + } + for _, c := range f.calls { + if strings.HasPrefix(c, "zfs destroy") { + t.Errorf("%s: destroy ran", name) + } + } + } + + f = &fake{reply: ok} + a = New(f.run) + if err := a.DestroyEmpty("tank"); err == nil || len(f.calls) != 0 { + t.Errorf("pool root: err=%v calls=%v", err, f.calls) + } +} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..6717b0c --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,41 @@ +# nasmon + +Lightweight NAS health monitor: web dashboard on port 8090 with CPU, memory +(incl. ZFS ARC), temperatures, physical disks with SMART, ZFS pools and datasets; +periodic SMART/ZFS checks with e-mail alerts. + +* Web UI: http://:8090 +* Settings: http://:8090/settings (also /etc/nasmon/config.json) +* State: /var/lib/nasmon/nasmon.db (SMART history, alert log) +* Service: systemctl status nasmon ; journalctl -u nasmon -f +* JSON API: /api/status, /api/history?hours=24, /api/disks//history?days=30 + +## ZFS datasets from the UI + +The ZFS page (http://:8090/zfs) can create datasets in a pool — name (nested +names like media/video are fine), owner uid:gid for the mountpoint, compression, +optional quota — and destroy datasets that are empty (no data, no children, no +snapshots; the check is repeated against the live system before "zfs destroy"). + +nasmon runs sandboxed (ProtectSystem=strict → own mount namespace), so these +commands are executed on the host through transient units: +`systemd-run --wait --pipe --collect zfs create …`. Every action is logged +(journalctl -u nasmon). + +nasmon has no authentication: keep port 8090 on the LAN only. + +## SMB / NFS shares from the UI + +http://:8090/shares manages Samba shares (guest or per-user, files always +written as the data owner uid 1000), SMB users (smbpasswd; a login-less Unix +account is created when needed) and NFS exports (host/IP/CIDR clients, rw/ro, +squash). Definitions are stored in /etc/nasmon/shares.json; nasmon regenerates +/etc/samba/smb.conf (whole file, header "Managed by nasmon") and +/etc/exports.d/nasmon.exports, validates with testparm, then reloads smbd / +exportfs -ra. A rejected config is rolled back to the .bak copy. Daemons are +enabled while at least one share exists and disabled otherwise. + +Alerts: the × on an open alert acknowledges it (hidden and not mailed until the +problem changes or clears); the × on a resolved alert removes it from the log. +Resolved alerts older than 7 days are hidden by default, older than 90 days +deleted. diff --git a/packaging/config.json b/packaging/config.json new file mode 100644 index 0000000..3147321 --- /dev/null +++ b/packaging/config.json @@ -0,0 +1,32 @@ +{ + "smtp": { + "host": "", + "port": 587, + "security": "starttls", + "user": "", + "pass": "", + "from": "", + "to": [], + "subject_prefix": "[nasmon]" + }, + "alerts_enabled": true, + "notify_resolved": true, + "remind_hours": 24, + "smart_interval_min": 30, + "smart_skip_standby": true, + "thresholds": { + "disk_temp_c": 50, + "cpu_temp_c": 80, + "pool_capacity_pct": 85, + "scrub_max_age_days": 45, + "rootfs_free_pct": 10, + "nvme_used_pct": 90, + "nvme_spare_pct": 10 + }, + "self_tests": { + "enabled": false, + "short_days": 7, + "long_days": 30, + "hour": 3 + } +} diff --git a/packaging/nasmon.service b/packaging/nasmon.service new file mode 100644 index 0000000..75c648c --- /dev/null +++ b/packaging/nasmon.service @@ -0,0 +1,27 @@ +[Unit] +Description=nasmon - NAS health monitor (SMART/ZFS dashboard and alerts) +Documentation=file:///usr/share/doc/nasmon/README.md +After=network-online.target zfs.target +Wants=network-online.target + +[Service] +Type=simple +# smartctl and zpool need root +User=root +ExecStart=/usr/bin/nasmon -listen :8090 -config /etc/nasmon/config.json -data /var/lib/nasmon +Restart=on-failure +RestartSec=5s + +# hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/lib/nasmon /etc/nasmon +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes + +[Install] +WantedBy=multi-user.target diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..558e8de --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,78 @@ +# nfpm packaging config — build with build.ps1/build.sh +# (or directly: nfpm package -f packaging/nfpm.yaml -p deb|rpm) +name: nasmon +arch: amd64 +platform: linux +version: ${NASMON_VERSION} +section: admin +priority: optional +maintainer: home_nas +description: | + Lightweight NAS health monitor. + Web dashboard (CPU, memory, temperatures, disks with SMART, ZFS pools) + and periodic SMART/ZFS checks with e-mail alerts. +homepage: https://gitea.virtlab.space/a.gritsenko/nasmon +license: MIT + +# Debian/Ubuntu package names by default; overrides.rpm below swaps in the +# Fedora/RHEL/Rocky names. ZFS has no native package on RHEL-family distros +# (needs the OpenZFS third-party repo), so it's intentionally left out of the +# rpm depends — see packaging/README.md. +depends: + - smartmontools + - zfsutils-linux + - util-linux +# file sharing is optional: the /shares page works once these are present +recommends: + - samba + - nfs-kernel-server + - wsdd + +overrides: + rpm: + depends: + - smartmontools + - util-linux + recommends: + - samba + - nfs-utils + - wsdd + +contents: + - src: ../dist/nasmon + dst: /usr/bin/nasmon + file_info: + mode: 0755 + - src: ./nasmon.service + dst: /lib/systemd/system/nasmon.service + packager: deb + file_info: + mode: 0644 + - src: ./nasmon.service + dst: /usr/lib/systemd/system/nasmon.service + packager: rpm + file_info: + mode: 0644 + - src: ./wsdd.service + dst: /etc/systemd/system/wsdd.service + type: config + file_info: + mode: 0644 + - src: ./config.json + dst: /etc/nasmon/config.json + type: config|noreplace + file_info: + mode: 0600 + - dst: /var/lib/nasmon + type: dir + file_info: + mode: 0750 + - src: ./README.md + dst: /usr/share/doc/nasmon/README.md + file_info: + mode: 0644 + +scripts: + postinstall: ./postinst.sh + preremove: ./prerm.sh + postremove: ./postrm.sh diff --git a/packaging/postinst.sh b/packaging/postinst.sh new file mode 100644 index 0000000..860ec7c --- /dev/null +++ b/packaging/postinst.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e +# config holds the SMTP password: root only +chmod 0600 /etc/nasmon/config.json 2>/dev/null || true +chmod 0750 /var/lib/nasmon +if [ -d /run/systemd/system ]; then + systemctl daemon-reload + systemctl enable nasmon.service >/dev/null 2>&1 || true + # restart on upgrade, start on fresh install + systemctl restart nasmon.service || true + # Windows network discovery, only when the wsdd package is present + if [ -x /usr/bin/wsdd ]; then + systemctl enable --now wsdd.service >/dev/null 2>&1 || true + fi +fi +exit 0 diff --git a/packaging/postrm.sh b/packaging/postrm.sh new file mode 100644 index 0000000..12cb3f3 --- /dev/null +++ b/packaging/postrm.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e +if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true +fi +if [ "$1" = "purge" ]; then + rm -rf /var/lib/nasmon /etc/nasmon +fi +exit 0 diff --git a/packaging/prerm.sh b/packaging/prerm.sh new file mode 100644 index 0000000..3d12006 --- /dev/null +++ b/packaging/prerm.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e +# dpkg passes "remove" here; rpm passes a numeric $1 (0 = last uninstall) - cover both. +if { [ "$1" = "remove" ] || [ "$1" = "0" ]; } && [ -d /run/systemd/system ]; then + systemctl stop nasmon.service >/dev/null 2>&1 || true + systemctl disable nasmon.service >/dev/null 2>&1 || true + systemctl disable --now wsdd.service >/dev/null 2>&1 || true +fi +exit 0 diff --git a/packaging/wsdd.service b/packaging/wsdd.service new file mode 100644 index 0000000..3ff6068 --- /dev/null +++ b/packaging/wsdd.service @@ -0,0 +1,16 @@ +# Installed by nasmon: the Ubuntu wsdd package ships no unit file. +# wsdd answers WS-Discovery so the NAS shows up under "Network" in Windows Explorer. +[Unit] +Description=Web Services Dynamic Discovery host daemon +After=network-online.target +Wants=network-online.target +ConditionPathExists=/usr/bin/wsdd + +[Service] +ExecStart=/usr/bin/wsdd --shortlog +DynamicUser=yes +Restart=on-failure +RestartSec=10s + +[Install] +WantedBy=multi-user.target