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 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:24:34 +03:00
co-authored by Claude Sonnet 5
commit 09159a5f31
61 changed files with 7882 additions and 0 deletions
+15
View File
@@ -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
+37
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
/nasmon
/nasmon.exe
dist/
+70
View File
@@ -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://<host>:8090`
* Settings: `http://<host>: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/<serial>/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_<version>_amd64.deb + dist\nasmon-<version>.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.
+87
View File
@@ -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_<version>_amd64.deb and dist\nasmon-<version>.<arch>.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+<timestamp>
.\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
}
+56
View File
@@ -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_<version>_amd64.deb and dist/nasmon-<version>.<arch>.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+<timestamp>
# 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
+84
View File
@@ -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)
}
+21
View File
@@ -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
)
+49
View File
@@ -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=
+305
View File
@@ -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)
}
}
+128
View File
@@ -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)
}
+212
View File
@@ -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: "<area>:<object>:<rule>".
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])
}
+188
View File
@@ -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()
}
+75
View File
@@ -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")
}
+92
View File
@@ -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
}
+28
View File
@@ -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
}
+20
View File
@@ -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
}
+13
View File
@@ -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") }
+77
View File
@@ -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
}
+62
View File
@@ -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)
}
}
+58
View File
@@ -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)
}
+291
View File
@@ -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
}
+165
View File
@@ -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)
}
}
+89
View File
@@ -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))
}
+226
View File
@@ -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 <pool>" (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
}
+130
View File
@@ -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)
}
}
+160
View File
@@ -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
}
+59
View File
@@ -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
}
+75
View File
@@ -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
}
+479
View File
@@ -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}}}
}
+203
View File
@@ -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
}
+179
View File
@@ -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
}
+114
View File
@@ -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://<nas>: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()
}
+338
View File
@@ -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")
}
}
+152
View File
@@ -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
}
+403
View File
@@ -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, &notnull, &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<?`, olderThan.Unix())
return err
}
// ---- alerts
type Alert struct {
ID int64 `json:"id"`
Key string `json:"key"`
Level string `json:"level"`
Title string `json:"title"`
Detail string `json:"detail"`
OpenedAt time.Time `json:"opened_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
LastNotifiedAt *time.Time `json:"last_notified_at,omitempty"`
AckedAt *time.Time `json:"acked_at,omitempty"`
}
// Acked reports whether the alert was acknowledged by the user.
func (a Alert) Acked() bool { return a.AckedAt != nil }
func scanAlert(sc interface{ Scan(...any) error }) (Alert, error) {
var a Alert
var opened int64
var resolved, notified, acked sql.NullInt64
err := sc.Scan(&a.ID, &a.Key, &a.Level, &a.Title, &a.Detail, &opened, &resolved, &notified, &acked)
if err != nil {
return a, err
}
if acked.Valid {
t := time.Unix(acked.Int64, 0)
a.AckedAt = &t
}
a.OpenedAt = time.Unix(opened, 0)
if resolved.Valid {
t := time.Unix(resolved.Int64, 0)
a.ResolvedAt = &t
}
if notified.Valid {
t := time.Unix(notified.Int64, 0)
a.LastNotifiedAt = &t
}
return a, nil
}
const alertCols = `id, key, level, title, detail, opened_at, resolved_at, last_notified_at, acked_at`
// OpenAlerts returns every unresolved alert, acknowledged ones included.
func (s *Store) OpenAlerts() ([]Alert, error) {
return s.queryAlerts(`SELECT ` + alertCols + ` FROM alerts WHERE resolved_at IS NULL ORDER BY opened_at DESC`)
}
// ActiveAlerts returns unresolved alerts that were not acknowledged — what the
// dashboard and the nav badge show.
func (s *Store) ActiveAlerts() ([]Alert, error) {
return s.queryAlerts(`SELECT ` + alertCols + ` FROM alerts WHERE resolved_at IS NULL AND acked_at IS NULL ORDER BY opened_at DESC`)
}
// RecentAlerts returns all open alerts plus those resolved at or after
// resolvedSince (zero time = every resolved alert), newest first.
func (s *Store) RecentAlerts(limit int, resolvedSince time.Time) ([]Alert, error) {
return s.queryAlerts(`SELECT `+alertCols+` FROM alerts WHERE resolved_at IS NULL OR resolved_at>=? 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<?`, t.Unix()).Scan(&n)
return n, err
}
func (s *Store) GetAlert(id int64) (*Alert, error) {
a, err := scanAlert(s.db.QueryRow(`SELECT `+alertCols+` FROM alerts WHERE id=?`, id))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &a, nil
}
func (s *Store) AckAlert(id int64, now time.Time) error {
_, err := s.db.Exec(`UPDATE alerts SET acked_at=? WHERE id=? AND resolved_at IS NULL`, now.Unix(), id)
return err
}
// UnackAlert shows an acknowledged alert again (user action, no new mail).
func (s *Store) UnackAlert(id int64) error {
_, err := s.db.Exec(`UPDATE alerts SET acked_at=NULL WHERE id=?`, id)
return err
}
// ReraiseAlert clears the acknowledgement and the delivery mark, so the alert
// is mailed again as new.
func (s *Store) ReraiseAlert(id int64) error {
_, err := s.db.Exec(`UPDATE alerts SET acked_at=NULL, last_notified_at=NULL WHERE id=?`, id)
return err
}
// DeleteAlert removes a resolved alert from the log; open alerts are kept.
func (s *Store) DeleteAlert(id int64) error {
_, err := s.db.Exec(`DELETE FROM alerts WHERE id=? AND resolved_at IS NOT NULL`, id)
return err
}
// PruneAlerts drops resolved alerts older than olderThan.
func (s *Store) PruneAlerts(olderThan time.Time) error {
_, err := s.db.Exec(`DELETE FROM alerts WHERE resolved_at IS NOT NULL AND resolved_at<?`, olderThan.Unix())
return err
}
func (s *Store) queryAlerts(q string, args ...any) ([]Alert, error) {
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Alert
for rows.Next() {
a, err := scanAlert(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) OpenAlert(key, level, title, detail string, now time.Time) (Alert, error) {
res, err := s.db.Exec(`INSERT INTO alerts(key, level, title, detail, opened_at) VALUES(?,?,?,?,?)`,
key, level, title, detail, now.Unix())
if err != nil {
return Alert{}, err
}
id, _ := res.LastInsertId()
return Alert{ID: id, Key: key, Level: level, Title: title, Detail: detail, OpenedAt: now}, nil
}
// UpdateAlertDetail refreshes level/detail of an open alert.
func (s *Store) UpdateAlertDetail(id int64, level, detail string) error {
_, err := s.db.Exec(`UPDATE alerts SET level=?, detail=? WHERE id=?`, level, detail, id)
return err
}
func (s *Store) ResolveAlert(id int64, now time.Time) error {
_, err := s.db.Exec(`UPDATE alerts SET resolved_at=? WHERE id=?`, now.Unix(), id)
return err
}
func (s *Store) MarkNotified(ids []int64, now time.Time) error {
for _, id := range ids {
if _, err := s.db.Exec(`UPDATE alerts SET last_notified_at=? WHERE id=?`, now.Unix(), id); err != nil {
return err
}
}
return nil
}
// ---- self-tests
func (s *Store) LastSelfTest(serial, kind string) (time.Time, error) {
var ts int64
err := s.db.QueryRow(`SELECT started_at FROM selftest_runs WHERE serial=? AND kind=?`, serial, kind).Scan(&ts)
if err == sql.ErrNoRows {
return time.Time{}, nil
}
return time.Unix(ts, 0), err
}
func (s *Store) SetSelfTest(serial, kind string, at time.Time) error {
_, err := s.db.Exec(`INSERT INTO selftest_runs(serial, kind, started_at) VALUES(?,?,?)
ON CONFLICT(serial, kind) DO UPDATE SET started_at=excluded.started_at`, serial, kind, at.Unix())
return err
}
// ---- known disks (for the "disk disappeared" rule)
type SeenDisk struct {
Serial string
Model string
Device string
LastSeen time.Time
}
func (s *Store) TouchDisk(serial, model, device string, now time.Time) error {
if serial == "" {
return nil
}
_, err := s.db.Exec(`INSERT INTO disks_seen(serial, model, device, last_seen) VALUES(?,?,?,?)
ON CONFLICT(serial) DO UPDATE SET model=excluded.model, device=excluded.device, last_seen=excluded.last_seen`,
serial, model, device, now.Unix())
return err
}
func (s *Store) SeenDisks() ([]SeenDisk, error) {
rows, err := s.db.Query(`SELECT serial, model, device, last_seen FROM disks_seen`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SeenDisk
for rows.Next() {
var d SeenDisk
var ts int64
if err := rows.Scan(&d.Serial, &d.Model, &d.Device, &ts); err != nil {
return nil, err
}
d.LastSeen = time.Unix(ts, 0)
out = append(out, d)
}
return out, rows.Err()
}
// ForgetDisk drops a disk from the known list (user removed it on purpose).
func (s *Store) ForgetDisk(serial string) error {
_, err := s.db.Exec(`DELETE FROM disks_seen WHERE serial=?`, serial)
return err
}
+48
View File
@@ -0,0 +1,48 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
"time"
)
// TestMigrateOldAlertsTable opens a database created by the first release
// (no acked_at column) and checks that the column is added.
func TestMigrateOldAlertsTable(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`CREATE TABLE 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);
INSERT INTO alerts(key, level, title, detail, opened_at) VALUES('k', 'warn', 't', 'd', 1)`)
if err != nil {
t.Fatal(err)
}
db.Close()
st, err := Open(path)
if err != nil {
t.Fatalf("open with migration: %v", err)
}
defer st.Close()
open, err := st.OpenAlerts()
if err != nil || len(open) != 1 || open[0].Acked() {
t.Fatalf("after migration: %+v %v", open, err)
}
if err := st.AckAlert(open[0].ID, time.Now()); err != nil {
t.Fatal(err)
}
act, _ := st.ActiveAlerts()
if len(act) != 0 {
t.Fatalf("acked alert listed as active")
}
// opening again must not fail (column already there)
st2, err := Open(path)
if err != nil {
t.Fatal(err)
}
st2.Close()
}
+469
View File
@@ -0,0 +1,469 @@
package web
import (
"encoding/json"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"nasmon/internal/collect"
"nasmon/internal/config"
"nasmon/internal/sched"
"nasmon/internal/store"
"nasmon/internal/zfsadm"
)
type dashVM struct {
S sched.Status
Th config.Thresholds
CPUSpark template.HTML
MemSpark template.HTML
TempSpark template.HTML
MemPct float64
ARCPct float64
SwapPct float64
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
st := s.sch.Status()
cfg := s.cfg.Get()
samples := s.ring.Since(time.Now().Add(-24*time.Hour), 240)
cpu := make([]float64, len(samples))
mem := make([]float64, len(samples))
tmp := make([]float64, len(samples))
for i, p := range samples {
cpu[i] = p.CPU
if st.Mem.Total > 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)
}
+284
View File
@@ -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(`<svg class="spark %s" viewBox="0 0 %d %d" preserveAspectRatio="none"></svg>`, 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(
`<svg class="spark %s" viewBox="0 0 %d %d" preserveAspectRatio="none"><polygon points="0,%d %s %d,%d"/><polyline points="%s"/></svg>`,
class, int(w), int(h), int(h), p, int(w), int(h), p))
}
+372
View File
@@ -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", "")
}
+212
View File
@@ -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(), "<b>data</b>") {
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)
}
}
+122
View File
@@ -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; }
+63
View File
@@ -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' ? ' <span class="badge crit">' + n + '</span>' : '');
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();
})();
+34
View File
@@ -0,0 +1,34 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .LastMailErr}}<div class="flash crit">Last mail delivery failed: {{.LastMailErr}}</div>{{end}}
<section class="card">
<h2>Alert log <span class="muted small">{{if .All}}everything{{else}}open + resolved in the last 7 days{{end}}</span></h2>
{{if not .List}}<p class="muted">Nothing has happened yet.</p>{{end}}
{{range .List}}
<div class="alert {{if .ResolvedAt}}resolved{{else if .Acked}}acked{{else}}{{.Level}}{{end}}">
{{if .ResolvedAt}}
<form method="post" action="/alerts/{{.ID}}/delete" class="x-form"><button type="submit" class="x" title="Remove from the log">&times;</button></form>
{{else if not .Acked}}
<form method="post" action="/alerts/{{.ID}}/ack" class="x-form"><button type="submit" class="x" title="Acknowledge: hide until it changes or resolves">&times;</button></form>
{{end}}
<div class="alert-title">
{{if .ResolvedAt}}<span class="badge">resolved</span>{{else if .Acked}}<span class="badge">acked</span>{{else}}<span class="badge {{.Level}}">{{.Level}}</span>{{end}}
{{.Title}}
</div>
<pre class="alert-detail">{{.Detail}}</pre>
<div class="muted small">
opened {{dt .OpenedAt}}
{{if .ResolvedAt}} · resolved {{dtp .ResolvedAt}}{{end}}
{{if .Acked}} · acknowledged {{dtp .AckedAt}} · <form method="post" action="/alerts/{{.ID}}/unack" class="inline x-form"><button type="submit" class="link">show again</button></form>{{end}}
{{if .LastNotifiedAt}} · mailed {{dtp .LastNotifiedAt}}{{else if not .ResolvedAt}} · <span class="warn-text">not mailed</span>{{end}}
· <span class="mono">{{.Key}}</span>
</div>
</div>
{{end}}
{{if .All}}<p class="small"><a href="/alerts">Hide resolved alerts older than 7 days</a></p>
{{else if .Hidden}}<p class="small"><a href="/alerts?all=1">Show all ({{.Hidden}} older resolved hidden)</a></p>{{end}}
<p class="muted small">&times; 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. &times; on a resolved alert removes it from the log; resolved alerts older than 90 days are removed automatically.</p>
</section>
{{end}}
{{end}}
+117
View File
@@ -0,0 +1,117 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{template "alertlist" .S.Alerts}}
<section class="grid metrics">
<div class="card metric">
<h2>CPU <span class="muted">{{.S.CPU.Cores}} cores</span></h2>
<div class="big {{levelClass .S.CPU.UsagePct 70 90}}">{{f0 .S.CPU.UsagePct}}%</div>
<div class="muted">load {{f1 (index .S.CPU.Load 0)}} / {{f1 (index .S.CPU.Load 1)}} / {{f1 (index .S.CPU.Load 2)}}</div>
{{.CPUSpark}}
<div class="muted small">last 24 h</div>
</div>
<div class="card metric">
<h2>Memory <span class="muted">{{bytes .S.Mem.Total}}</span></h2>
<div class="big {{levelClass .MemPct 80 95}}">{{f0 .MemPct}}%</div>
<div class="bar">
<span class="fill mem" style="width:{{f1 .MemPct}}%"></span>
{{if .S.Mem.ARCSize}}<span class="fill arc" style="width:{{f1 .ARCPct}}%"></span>{{end}}
</div>
<div class="muted">used {{bytes .S.Mem.Used}}{{if .S.Mem.ARCSize}} · <span class="arc-dot"></span>ARC {{bytes .S.Mem.ARCSize}} / {{bytes .S.Mem.ARCMax}}{{end}}</div>
{{if .S.Mem.SwapTotal}}<div class="muted">swap {{bytes .S.Mem.SwapUsed}} / {{bytes .S.Mem.SwapTotal}} ({{f0 .SwapPct}}%)</div>{{end}}
{{.MemSpark}}
<div class="muted small">last 24 h</div>
</div>
<div class="card metric">
<h2>Temperature</h2>
{{if .S.HasCPUTemp}}<div class="big {{tempClass .S.CPUTemp .Th.CPUTempC}}">{{f0 .S.CPUTemp}}°C</div><div class="muted">CPU</div>{{else}}<div class="big muted">—</div><div class="muted">no CPU sensor</div>{{end}}
{{.TempSpark}}
<details class="sensors">
<summary class="muted small">{{len .S.Temps}} sensors</summary>
<table class="compact">
{{range .S.Temps}}<tr><td class="muted">{{.Chip}}</td><td>{{.Label}}</td><td class="num">{{f0 .C}}°C</td></tr>{{end}}
</table>
</details>
</div>
<div class="card metric">
<h2>System</h2>
<div class="kv">
<div><span class="muted">host</span> {{.S.Host}}</div>
<div><span class="muted">root fs</span> {{bytes .S.RootFS.Free}} free of {{bytes .S.RootFS.Total}}</div>
<div><span class="muted">SMART check</span> {{ago .S.LastSmartCheck}}{{if .S.SmartRunning}} <span class="badge">running</span>{{else}} · next {{dt .S.NextSmartCheck}}{{end}}</div>
<div><span class="muted">nasmon</span> {{.S.Version}} · up {{dur .S.Uptime}}</div>
{{if .S.LastMailErr}}<div class="crit-text">mail error: {{.S.LastMailErr}}</div>{{end}}
</div>
<form method="post" action="/smart/run" class="inline"><button type="submit" {{if .S.SmartRunning}}disabled{{end}}>Check SMART now</button></form>
</div>
</section>
<section class="card">
<h2>Disks</h2>
{{if .S.DisksErr}}<div class="flash crit">{{.S.DisksErr}}</div>{{end}}
<div class="tablewrap"><table>
<thead><tr><th>Device</th><th>Model</th><th>Serial</th><th class="num">Size</th><th>Bus</th><th class="num">Temp</th><th>Health</th><th class="num">Power-on</th><th>Self-test</th><th>Warnings</th></tr></thead>
<tbody>
{{range .S.Disks}}{{$sm := .Smart}}
<tr>
<td><a href="/disks/{{.Serial}}">{{.Name}}</a></td>
<td>{{.Model}}</td>
<td class="mono small">{{.Serial}}</td>
<td class="num">{{bytes .Size}}</td>
<td class="muted">{{.Tran}}{{if not .Rota}} ssd{{end}}</td>
{{if $sm}}
<td class="num">{{if $sm.HasTemp}}<span class="{{tempClass $sm.TempC $.Data.Th.DiskTempC}}">{{$sm.TempC}}°C</span>{{if .Standby}} <span class="muted small" title="disk in standby, value from last read">zz</span>{{end}}{{else}}—{{end}}</td>
<td>{{if .Error}}<span class="badge warn" title="{{.Error}}">n/a</span>{{else if not $sm.Supported}}<span class="muted">n/a</span>{{else if $sm.Healthy}}<span class="badge ok">PASSED</span>{{else}}<span class="badge crit">FAILED</span>{{end}}</td>
<td class="num">{{if $sm.PowerOnHrs}}{{$sm.PowerOnHrs}} h{{else}}—{{end}}</td>
<td class="small">{{with $sm.LastTest}}{{if .InProgress}}<span class="badge">{{.Remaining}}% left</span>{{else}}<span class="{{if .Passed}}ok{{else}}crit{{end}}">{{.Status}}</span>{{end}}{{else}}—{{end}}</td>
<td class="small">
{{range $sm.Attrs}}{{if and (or (eq .ID 5) (eq .ID 187) (eq .ID 188) (eq .ID 197) (eq .ID 198)) (gt .Raw 0)}}<span class="badge crit" title="{{.Name}}">{{.ID}}: {{.Raw}}</span> {{end}}{{if .WhenFailed}}<span class="badge crit">{{.ID}} {{.WhenFailed}}</span> {{end}}{{end}}
{{with $sm.NVMe}}{{if .CriticalWarning}}<span class="badge crit">warn 0x{{printf "%02x" .CriticalWarning}}</span> {{end}}{{if .MediaErrors}}<span class="badge crit">media err {{.MediaErrors}}</span> {{end}}<span class="muted">{{.PercentageUsed}}% used</span>{{end}}
</td>
{{else}}
<td class="num">—</td><td>{{if .Standby}}<span class="muted">standby</span>{{else if .Error}}<span class="badge warn" title="{{.Error}}">n/a</span>{{else}}<span class="muted">pending</span>{{end}}</td><td></td><td></td><td></td>
{{end}}
</tr>
{{else}}<tr><td colspan="10" class="muted">no disks found</td></tr>{{end}}
</tbody>
</table></div>
</section>
<section class="card">
<h2>ZFS pools <a class="muted small" href="/zfs">details</a></h2>
{{if .S.PoolsErr}}<div class="flash crit">{{.S.PoolsErr}}</div>{{end}}
<div class="tablewrap"><table>
<thead><tr><th>Pool</th><th>Health</th><th class="num">Used</th><th class="num">Free</th><th class="num">Size</th><th>Capacity</th><th class="num">Frag</th><th>Last scrub</th><th>Errors</th></tr></thead>
<tbody>
{{range .S.Pools}}
<tr>
<td><a href="/zfs#{{.Name}}">{{.Name}}</a></td>
<td><span class="badge {{healthClass .Health}}">{{.Health}}</span></td>
<td class="num">{{bytes .Alloc}}</td>
<td class="num">{{bytes .Free}}</td>
<td class="num">{{bytes .Size}}</td>
<td><div class="bar small"><span class="fill {{capClass .CapPct 75 90}}" style="width:{{.CapPct}}%"></span></div> <span class="small">{{.CapPct}}%</span></td>
<td class="num">{{.FragPct}}%</td>
<td class="small">{{if .Scrubbing}}<span class="badge">scrubbing</span>{{else if .LastScrub}}{{agop .LastScrub}}{{else}}<span class="muted">never</span>{{end}}</td>
<td class="small">{{if .Errors}}{{.Errors}}{{end}}</td>
</tr>
{{else}}<tr><td colspan="9" class="muted">no pools</td></tr>{{end}}
</tbody>
</table></div>
</section>
<section class="card">
<h2>Datasets</h2>
<div class="tablewrap"><table>
<thead><tr><th>Dataset</th><th>Mountpoint</th><th class="num">Used</th><th class="num">Referenced</th><th class="num">Available</th></tr></thead>
<tbody>
{{range .S.Datasets}}<tr><td class="mono">{{.Name}}</td><td class="muted mono small">{{.Mountpoint}}</td><td class="num">{{bytes .Used}}</td><td class="num">{{bytes .Refer}}</td><td class="num">{{bytes .Avail}}</td></tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{end}}
+80
View File
@@ -0,0 +1,80 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}{{$sm := .D.Smart}}
<section class="card">
<h2>{{.D.Model}} <span class="muted">{{.D.Serial}}</span></h2>
<div class="kv cols">
<div><span class="muted">device</span> {{.D.Path}}</div>
{{if .D.Size}}<div><span class="muted">size</span> {{bytes .D.Size}}</div>{{end}}
<div><span class="muted">bus</span> {{.D.Tran}}{{if .D.Rota}} (rotational){{else}} (ssd){{end}}</div>
{{if $sm}}
<div><span class="muted">protocol</span> {{$sm.Protocol}}</div>
<div><span class="muted">firmware</span> {{$sm.Firmware}}</div>
<div><span class="muted">health</span> {{if not $sm.Supported}}<span class="muted">not supported</span>{{else if $sm.Healthy}}<span class="badge ok">PASSED</span>{{else}}<span class="badge crit">FAILED</span>{{end}}</div>
<div><span class="muted">temperature</span> {{if $sm.HasTemp}}<span class="{{tempClass $sm.TempC $.Data.Th.DiskTempC}}">{{$sm.TempC}}°C</span>{{else}}—{{end}}{{if .D.Standby}} <span class="muted">(standby, last read)</span>{{end}}</div>
<div><span class="muted">power-on</span> {{hours $sm.PowerOnHrs}}</div>
<div><span class="muted">power cycles</span> {{$sm.PowerCycles}}</div>
<div><span class="muted">last SMART read</span> {{dt $sm.CheckedAt}}</div>
{{with $sm.LastTest}}<div><span class="muted">self-test</span> {{if .InProgress}}<span class="badge">in progress, {{.Remaining}}% left</span>{{else}}{{.Type}}: <span class="{{if .Passed}}ok{{else}}crit{{end}}">{{.Status}}</span> <span class="muted">at {{.Hours}} h</span>{{end}}</div>{{end}}
{{if $sm.Error}}<div class="crit-text">{{$sm.Error}}</div>{{end}}
{{end}}
</div>
{{if .Known}}<form method="post" action="/disks/{{.D.Serial}}/forget" class="inline" onsubmit="return confirm('Forget this disk? It will no longer trigger a \'disk disappeared\' alert.')"><button type="submit" class="secondary">Forget disk</button></form>{{end}}
</section>
{{if $sm}}
{{with $sm.NVMe}}
<section class="card">
<h2>NVMe health</h2>
<div class="kv cols">
<div><span class="muted">critical warning</span> {{if .CriticalWarning}}<span class="crit">0x{{printf "%02x" .CriticalWarning}}</span>{{else}}<span class="ok">none</span>{{end}}</div>
<div><span class="muted">percentage used</span> {{.PercentageUsed}}%</div>
<div><span class="muted">available spare</span> {{.AvailableSpare}}% <span class="muted">(threshold {{.SpareThreshold}}%)</span></div>
<div><span class="muted">media errors</span> {{if .MediaErrors}}<span class="crit">{{.MediaErrors}}</span>{{else}}0{{end}}</div>
<div><span class="muted">error log entries</span> {{.ErrLogEntries}}</div>
<div><span class="muted">unsafe shutdowns</span> {{.UnsafeShutdowns}}</div>
</div>
</section>
{{end}}
<section class="card">
<h2>Temperature, last 30 days <span class="muted small">{{len .History}} snapshots</span></h2>
{{.TempSpark}}
</section>
{{if $sm.Attrs}}
<section class="card">
<h2>SMART attributes</h2>
<div class="tablewrap"><table>
<thead><tr><th class="num">ID</th><th>Name</th><th class="num">Value</th><th class="num">Worst</th><th class="num">Thresh</th><th class="num">Raw</th><th>Failed</th></tr></thead>
<tbody>
{{range $sm.Attrs}}
{{$crit := and (or (eq .ID 5) (eq .ID 187) (eq .ID 188) (eq .ID 197) (eq .ID 198)) (gt .Raw 0)}}
<tr class="{{if or $crit .WhenFailed}}row-crit{{end}}">
<td class="num">{{.ID}}</td><td class="mono">{{.Name}}</td>
<td class="num">{{.Value}}</td><td class="num">{{.Worst}}</td><td class="num">{{.Thresh}}</td>
<td class="num">{{.RawString}}</td>
<td>{{if .WhenFailed}}<span class="badge crit">{{.WhenFailed}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{if .History}}
<section class="card">
<h2>History</h2>
<div class="tablewrap"><table class="compact">
<thead><tr><th>Time</th><th class="num">Temp</th><th>Health</th><th class="num">Hours</th><th class="num">{{if $sm.NVMe}}Media err{{else}}Realloc (5){{end}}</th><th class="num">Pending (197)</th><th class="num">CRC (199)</th></tr></thead>
<tbody>
{{range .HistoryRev}}<tr><td class="small">{{dt (unix .T)}}</td><td class="num">{{.TempC}}°C</td><td>{{if .Healthy}}<span class="ok">ok</span>{{else}}<span class="crit">FAIL</span>{{end}}</td><td class="num">{{.Hours}}</td><td class="num">{{.Realloc}}</td><td class="num">{{.Pending}}</td><td class="num">{{.CRC}}</td></tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{else}}
<section class="card"><p class="muted">No SMART data yet{{if .D.Standby}} — disk is in standby, waiting for it to wake up{{end}}.</p></section>
{{end}}
{{end}}
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · nasmon</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232d7ff9'/%3E%3C/svg%3E">
</head>
<body data-page="{{.Page}}">
<header class="top">
<a class="brand" href="/">nasmon</a>
<nav>
<a href="/" {{if eq .Page "dashboard"}}class="on"{{end}}>Dashboard</a>
<a href="/zfs" {{if eq .Page "zfs"}}class="on"{{end}}>ZFS</a>
<a href="/shares" {{if eq .Page "shares"}}class="on"{{end}}>Shares</a>
<a href="/alerts" {{if eq .Page "alerts"}}class="on"{{end}}>Alerts{{if .Alerts}} <span class="badge crit">{{.Alerts}}</span>{{end}}</a>
<a href="/settings" {{if eq .Page "settings"}}class="on"{{end}}>Settings</a>
</nav>
<span class="refresh" id="refresh-state" title="auto-refresh"></span>
</header>
<main id="content">{{template "content" .}}</main>
<script src="/static/app.js"></script>
</body>
</html>{{end}}
{{define "flash"}}{{if .Flash}}<div class="flash ok">{{.Flash}}</div>{{end}}{{if .Error}}<div class="flash crit">{{.Error}}</div>{{end}}{{end}}
{{define "alertlist"}}{{if .}}
<section class="card alerts-open">
<h2>Open alerts</h2>
{{range .}}<div class="alert {{.Level}}">
<form method="post" action="/alerts/{{.ID}}/ack" class="x-form"><button type="submit" class="x" title="Acknowledge: hide until it changes or resolves">&times;</button></form>
<div class="alert-title"><span class="badge {{.Level}}">{{.Level}}</span> {{.Title}}</div>
<pre class="alert-detail">{{.Detail}}</pre>
<div class="muted">since {{dt .OpenedAt}}</div>
</div>{{end}}
</section>{{end}}{{end}}
+76
View File
@@ -0,0 +1,76 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}{{$c := .C}}
<form method="post" action="/settings" class="settings">
<section class="card">
<h2>E-mail (SMTP)</h2>
<div class="form-grid">
<label>Server <input name="smtp_host" value="{{$c.SMTP.Host}}" placeholder="smtp.yandex.ru"></label>
<label>Port <input name="smtp_port" type="number" min="1" max="65535" value="{{$c.SMTP.Port}}"></label>
<label>Security
<select name="smtp_security">
<option value="starttls" {{if eq $c.SMTP.Security "starttls"}}selected{{end}}>STARTTLS (587)</option>
<option value="tls" {{if eq $c.SMTP.Security "tls"}}selected{{end}}>TLS / SSL (465)</option>
<option value="none" {{if eq $c.SMTP.Security "none"}}selected{{end}}>none (25)</option>
</select>
</label>
<label>User <input name="smtp_user" value="{{$c.SMTP.User}}" autocomplete="off"></label>
<label>Password <input name="smtp_pass" type="password" autocomplete="new-password" placeholder="{{if .HasPass}}•••••••• (unchanged){{else}}not set{{end}}">
{{if .HasPass}}<span class="small"><input type="checkbox" name="smtp_clear_pass" value="1"> clear stored password</span>{{end}}</label>
<label>From <input name="smtp_from" value="{{$c.SMTP.From}}" placeholder="same as user"></label>
<label class="wide">Recipients <textarea name="smtp_to" rows="2" placeholder="one address per line">{{.ToText}}</textarea></label>
<label>Subject prefix <input name="smtp_prefix" value="{{$c.SMTP.SubjectPrefix}}"></label>
</div>
<p class="muted small">Gmail/Yandex/Mail.ru need an app password, not the account password. Yandex: smtp.yandex.ru 465 TLS; Gmail: smtp.gmail.com 587 STARTTLS.</p>
</section>
<section class="card">
<h2>Alerts</h2>
<div class="form-grid">
<label class="check"><input type="checkbox" name="alerts_enabled" value="1" {{if $c.AlertsEnabled}}checked{{end}}> Send e-mail alerts</label>
<label class="check"><input type="checkbox" name="notify_resolved" value="1" {{if $c.NotifyResolved}}checked{{end}}> Also mail when a problem clears</label>
<label>Remind about open alerts every <span class="unit"><input name="remind_hours" type="number" min="0" value="{{$c.RemindHours}}"> hours (0 = never)</span></label>
</div>
</section>
<section class="card">
<h2>SMART polling</h2>
<div class="form-grid">
<label>Check every <span class="unit"><input name="smart_interval" type="number" min="1" value="{{$c.SmartIntervalMin}}"> minutes</span></label>
<label class="check"><input type="checkbox" name="smart_skip_standby" value="1" {{if $c.SmartSkipStandby}}checked{{end}}> Do not wake disks in standby</label>
</div>
</section>
<section class="card">
<h2>Thresholds</h2>
<div class="form-grid">
<label>Disk temperature <span class="unit"><input name="th_disk_temp" type="number" min="0" value="{{$c.Thresholds.DiskTempC}}"> °C</span></label>
<label>CPU temperature <span class="unit"><input name="th_cpu_temp" type="number" min="0" value="{{$c.Thresholds.CPUTempC}}"> °C</span></label>
<label>Pool capacity <span class="unit"><input name="th_pool_cap" type="number" min="0" max="100" value="{{$c.Thresholds.PoolCapacityPct}}"> %</span></label>
<label>Scrub older than <span class="unit"><input name="th_scrub_days" type="number" min="0" value="{{$c.Thresholds.ScrubMaxAgeDays}}"> days</span></label>
<label>Root filesystem free below <span class="unit"><input name="th_rootfs_free" type="number" min="0" max="100" value="{{$c.Thresholds.RootFSFreePct}}"> %</span></label>
<label>NVMe percentage used <span class="unit"><input name="th_nvme_used" type="number" min="0" max="100" value="{{$c.Thresholds.NVMeUsedPct}}"> %</span></label>
<label>NVMe spare below <span class="unit"><input name="th_nvme_spare" type="number" min="0" max="100" value="{{$c.Thresholds.NVMeSparePct}}"> %</span></label>
</div>
<p class="muted small">0 disables a threshold. SMART health FAILED, reallocated/pending sectors, pool state and vdev errors always alert.</p>
</section>
<section class="card">
<h2>Scheduled self-tests</h2>
<div class="form-grid">
<label class="check"><input type="checkbox" name="st_enabled" value="1" {{if $c.SelfTests.Enabled}}checked{{end}}> Run SMART self-tests automatically</label>
<label>Short test every <span class="unit"><input name="st_short_days" type="number" min="0" value="{{$c.SelfTests.ShortDays}}"> days</span></label>
<label>Long test every <span class="unit"><input name="st_long_days" type="number" min="0" value="{{$c.SelfTests.LongDays}}"> days</span></label>
<label>Start window from <span class="unit"><input name="st_hour" type="number" min="0" max="23" value="{{$c.SelfTests.Hour}}">:00, 3 hours</span></label>
</div>
<p class="muted small">Tests start only for disks that are awake. A long test on a large HDD takes many hours; disk stays usable.</p>
</section>
<div class="actions">
<button type="submit" name="action" value="save">Save</button>
<button type="submit" name="action" value="test" class="secondary">Save &amp; send test e-mail</button>
</div>
</form>
{{end}}
{{end}}
+141
View File
@@ -0,0 +1,141 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .ApplyErr}}<div class="flash crit">Configuration saved but not applied: {{.ApplyErr}}
<form method="post" action="/shares/apply" class="inline"><button type="submit" class="secondary">Apply again</button></form></div>{{end}}
<datalist id="mountpoints">{{range .Mountpoints}}<option value="{{.}}">{{end}}</datalist>
<section class="card" id="smb">
<h2>SMB (Windows / macOS network shares)
{{if not .Svc.SMBInstalled}}<span class="badge warn">not installed</span>
{{else if .Svc.SMBActive}}<span class="badge ok">running</span> <span class="muted small">{{.Svc.SMBSessions}} session(s)</span>
{{else}}<span class="badge">stopped</span>{{end}}
</h2>
{{if not .Svc.SMBInstalled}}<p class="warn-text">Samba is not installed — run <span class="mono">sudo apt install samba wsdd</span> on the NAS (install.sh does it). Shares can be defined now and are applied once Samba is there.</p>{{end}}
<form method="post" action="/shares/smb/settings" class="form-grid">
<label class="check"><input type="checkbox" name="enabled" value="1" {{if .C.SMB.Enabled}}checked{{end}}> SMB server enabled</label>
<label>Workgroup <input name="workgroup" value="{{.C.SMB.Workgroup}}"></label>
<label>Description <input name="server_string" value="{{.C.SMB.ServerString}}"></label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Save SMB settings</button></span></div>
</form>
<h3>Shares</h3>
<div class="tablewrap"><table>
<thead><tr><th>Name</th><th>Path</th><th>Access</th><th>Who</th><th>Connect as</th><th></th></tr></thead>
<tbody>
{{range .C.SMB.Shares}}
<tr>
<td><b>{{.Name}}</b>{{if .Comment}} <span class="muted small">{{.Comment}}</span>{{end}}{{if not .Browseable}} <span class="badge">hidden</span>{{end}}</td>
<td class="mono small">{{.Path}}</td>
<td>{{if .ReadOnly}}read-only{{else}}read/write{{end}}</td>
<td>{{if .Guest}}<span class="ok">guest</span> (no password){{else}}{{join .Users ", "}}{{end}}</td>
<td class="mono small">\\{{$.Data.Host}}\{{.Name}}</td>
<td class="small nowrap">
<a href="/shares?edit={{.Name}}#smbform">edit</a> ·
<form method="post" action="/shares/smb/delete" class="inline" onsubmit="return confirm('Delete share {{.Name}}? Files are not touched.')"><input type="hidden" name="name" value="{{.Name}}"><button type="submit" class="link">delete</button></form>
</td>
</tr>
{{else}}<tr><td colspan="6" class="muted">No SMB shares yet.</td></tr>{{end}}
</tbody>
</table></div>
<details class="newds" id="smbform" {{if or (not .Form.New) .Form.Path $.Error}}open{{end}}>
<summary>{{if .Form.New}}New share{{else}}Edit share {{.Form.Orig}}{{end}}</summary>
<form method="post" action="/shares/smb/save" class="ds-form">
<input type="hidden" name="orig" value="{{.Form.Orig}}">
<div class="form-grid">
<label>Share name <input name="name" required value="{{.Form.Name}}" placeholder="documents"></label>
<label>Path <input name="path" required list="mountpoints" value="{{.Form.Path}}" placeholder="/nas_pool_1_8_gb/documents"></label>
<label>Comment <input name="comment" value="{{.Form.Comment}}"></label>
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .Form.ReadOnly}}checked{{end}}> Read-only</label>
<label class="check"><input type="checkbox" name="guest" value="1" {{if .Form.Guest}}checked{{end}}> Guest access (anyone on the LAN, no password)</label>
<label class="check"><input type="checkbox" name="browseable" value="1" {{if .Form.Browseable}}checked{{end}}> Visible in the share list</label>
<label class="check"><input type="checkbox" name="hide_sys" value="1" {{if .Form.HideSys}}checked{{end}}> Hide ._*, .DS_Store, Thumbs.db, desktop.ini</label>
<div class="wide field">
<span class="field-name">Allowed users (when guest access is off)</span>
<span class="checks">{{range .Users}}<label class="check-item"><input type="checkbox" name="users" value="{{.}}" {{if contains $.Data.Form.Users .}}checked{{end}}> {{.}}</label>{{else}}<span class="muted">no SMB users yet — add one below</span>{{end}}</span>
</div>
</div>
<p class="small muted">Files are read and written as <b>{{.Owner}}</b> regardless of the SMB user, so containers and shares see the same ownership.</p>
<button type="submit">{{if .Form.New}}Create share{{else}}Save share{{end}}</button>
{{if not .Form.New}}<a href="/shares" class="small">cancel</a>{{end}}
</form>
</details>
<h3 id="users">SMB users</h3>
<div class="tablewrap"><table class="compact">
<tbody>
{{range .Users}}<tr><td class="mono">{{.}}</td><td class="small">
<form method="post" action="/shares/users/delete" class="inline" onsubmit="return confirm('Delete SMB user {{.}}?')"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="link">delete</button></form>
</td></tr>{{else}}<tr><td class="muted">No SMB users. Guest shares work without users.</td></tr>{{end}}
</tbody>
</table></div>
<form method="post" action="/shares/users/save" class="form-grid">
<label>User <input name="name" required placeholder="alice" list="smbusers" autocomplete="off"></label>
<datalist id="smbusers">{{range .Users}}<option value="{{.}}">{{end}}</datalist>
<label>Password <input name="password" type="password" required minlength="4" autocomplete="new-password"></label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Add user / set password</button></span></div>
</form>
<p class="muted small">Windows: <span class="mono">\\{{.Host}}\share</span> in Explorer, or map a network drive. macOS: Finder → Go → Connect to Server → <span class="mono">smb://{{.Host}}/share</span>.
Note: Windows 10/11 Pro/Enterprise refuse <i>guest</i> 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).</p>
</section>
<section class="card" id="nfs">
<h2>NFS (Linux / other NAS)
{{if not .Svc.NFSInstalled}}<span class="badge warn">not installed</span>
{{else if .Svc.NFSActive}}<span class="badge ok">running</span>
{{else}}<span class="badge">stopped</span>{{end}}
</h2>
{{if not .Svc.NFSInstalled}}<p class="warn-text">NFS server is not installed — run <span class="mono">sudo apt install nfs-kernel-server</span> on the NAS (install.sh does it).</p>{{end}}
<form method="post" action="/shares/nfs/settings" class="form-grid">
<label class="check"><input type="checkbox" name="enabled" value="1" {{if .C.NFS.Enabled}}checked{{end}}> NFS server enabled</label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Save NFS settings</button></span></div>
</form>
<div class="tablewrap"><table>
<thead><tr><th>Path</th><th>Clients</th><th>Access</th><th>Squash</th><th>Mount</th><th></th></tr></thead>
<tbody>
{{range .C.NFS.Exports}}
<tr>
<td class="mono small">{{.Path}}</td>
<td class="mono small">{{join .Clients ", "}}</td>
<td>{{if .ReadOnly}}read-only{{else}}read/write{{end}}{{if .Async}} <span class="muted small">async</span>{{end}}</td>
<td>{{.Squash}}</td>
<td class="mono small">{{$.Data.Host}}:{{.Path}}</td>
<td class="small nowrap">
<a href="/shares?nedit={{.Path}}#nfsform">edit</a> ·
<form method="post" action="/shares/nfs/delete" class="inline" onsubmit="return confirm('Remove NFS export {{.Path}}?')"><input type="hidden" name="path" value="{{.Path}}"><button type="submit" class="link">delete</button></form>
</td>
</tr>
{{else}}<tr><td colspan="6" class="muted">No NFS exports yet.</td></tr>{{end}}
</tbody>
</table></div>
<details class="newds" id="nfsform" {{if or (not .NForm.New) .NForm.Path}}open{{end}}>
<summary>{{if .NForm.New}}New export{{else}}Edit export {{.NForm.Orig}}{{end}}</summary>
<form method="post" action="/shares/nfs/save" class="ds-form">
<input type="hidden" name="orig" value="{{.NForm.Orig}}">
<div class="form-grid">
<label>Path <input name="path" required list="mountpoints" value="{{.NForm.Path}}" placeholder="/nas_pool_1_8_gb/documents"></label>
<label>Clients <input name="clients" required value="{{.NForm.Clients}}" placeholder="192.168.101.0/24, pc.lan, *"></label>
<label>Squash
<select name="squash">
<option value="root" {{if eq .NForm.Squash "root"}}selected{{end}}>root → {{.Owner}} (root_squash)</option>
<option value="all" {{if eq .NForm.Squash "all"}}selected{{end}}>everyone → {{.Owner}} (all_squash)</option>
<option value="none" {{if eq .NForm.Squash "none"}}selected{{end}}>none (no_root_squash, trusted clients only)</option>
</select>
</label>
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .NForm.ReadOnly}}checked{{end}}> Read-only</label>
<label class="check"><input type="checkbox" name="async" value="1" {{if .NForm.Async}}checked{{end}}> async writes (faster, less safe on power loss)</label>
</div>
<p class="small muted">Nested datasets under the path are exported too (crossmnt). Clients: host names, IPs, networks like 192.168.101.0/24, or * for everyone.</p>
<button type="submit">{{if .NForm.New}}Create export{{else}}Save export{{end}}</button>
{{if not .NForm.New}}<a href="/shares" class="small">cancel</a>{{end}}
</form>
</details>
<p class="muted small">Linux: <span class="mono">sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas</span>; list exports with <span class="mono">showmount -e {{.Host}}</span>.</p>
</section>
{{end}}
{{end}}
+82
View File
@@ -0,0 +1,82 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .S.PoolsErr}}<div class="flash crit">{{.S.PoolsErr}}</div>{{end}}
{{$form := .Form}}
{{range .S.Pools}}
<section class="card" id="{{.Name}}">
<h2>{{.Name}} <span class="badge {{healthClass .Health}}">{{.Health}}</span></h2>
<div class="kv cols">
<div><span class="muted">size</span> {{bytes .Size}}</div>
<div><span class="muted">allocated</span> {{bytes .Alloc}} ({{.CapPct}}%)</div>
<div><span class="muted">free</span> {{bytes .Free}}</div>
<div><span class="muted">fragmentation</span> {{.FragPct}}%</div>
<div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div>
<div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div>
</div>
<div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div>
{{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}}
{{if .Status}}<p class="small warn-text"><span class="muted">status:</span> {{.Status}}</p>{{end}}
{{if .Action}}<p class="small"><span class="muted">action:</span> {{.Action}}</p>{{end}}
<div class="tablewrap"><table class="compact vdevs">
<thead><tr><th>vdev</th><th>State</th><th class="num">Read</th><th class="num">Write</th><th class="num">Cksum</th><th></th></tr></thead>
<tbody>
{{range .Vdevs}}
<tr class="{{if or .Read .Write .Cksum}}row-crit{{end}}">
<td class="mono" style="padding-left:{{.Depth}}.5em">{{.Name}}</td>
<td>{{if .State}}<span class="{{healthClass .State}}">{{.State}}</span>{{end}}</td>
<td class="num">{{.Read}}</td><td class="num">{{.Write}}</td><td class="num">{{.Cksum}}</td>
<td class="muted small">{{.Note}}</td>
</tr>
{{end}}
</tbody>
</table></div>
<details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}>
<summary>New dataset in {{.Name}}</summary>
<form method="post" action="/zfs/create" class="ds-form">
<input type="hidden" name="pool" value="{{.Name}}">
<div class="form-grid">
<label>Name <input name="name" required placeholder="media/video" value="{{if eq $form.Pool .Name}}{{$form.Name}}{{end}}"></label>
<label>Owner (uid:gid) <input name="owner" value="{{$form.Owner}}" placeholder="1000:1000"></label>
<label>Compression
<select name="compression">
<option value="" {{if eq $form.Compression ""}}selected{{end}}>inherit</option>
<option value="lz4" {{if eq $form.Compression "lz4"}}selected{{end}}>lz4</option>
<option value="zstd" {{if eq $form.Compression "zstd"}}selected{{end}}>zstd</option>
<option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option>
</select>
</label>
<label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label>
</div>
<p class="small muted">Creates {{.Name}}/&lt;name&gt; (parents are created too), mounts it at /{{.Name}}/&lt;name&gt; and chowns the mountpoint to the owner.</p>
<button type="submit">Create dataset</button>
</form>
</details>
</section>
{{else}}<section class="card"><p class="muted">No ZFS pools found.</p></section>{{end}}
<section class="card">
<h2>Datasets</h2>
<div class="tablewrap"><table>
<thead><tr><th>Dataset</th><th>Mountpoint</th><th class="num">Used</th><th class="num">Referenced</th><th class="num">Available</th><th></th></tr></thead>
<tbody>
{{range .Datasets}}<tr>
<td class="mono">{{.Name}}</td><td class="muted mono small">{{.Mountpoint}}</td>
<td class="num">{{bytes .Used}}</td><td class="num">{{bytes .Refer}}</td><td class="num">{{bytes .Avail}}</td>
<td class="small">{{if hasPrefix .Mountpoint "/"}}<a href="/shares?path={{.Mountpoint}}">share…</a> {{end}}{{if .CanDestroy}}
<details class="destroy destroy-{{.DomID}}">
<summary class="muted">destroy…</summary>
<form method="post" action="/zfs/destroy" class="inline ds-destroy">
<input type="hidden" name="name" value="{{.Name}}">
<input name="confirm" placeholder="type {{.Name}} to confirm" size="30" required>
<button type="submit" class="secondary danger">Destroy empty dataset</button>
</form>
</details>
{{end}}</td>
</tr>{{end}}
</tbody>
</table></div>
<p class="small muted">Only empty datasets (no data, no children, no snapshots) can be destroyed here.</p>
</section>
{{end}}
{{end}}
+406
View File
@@ -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, "<no value>") || 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(), "<no value>") {
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)
}
}
+214
View File
@@ -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")
}
+153
View File
@@ -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)
}
}
+41
View File
@@ -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://<host>:8090
* Settings: http://<host>: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/<serial>/history?days=30
## ZFS datasets from the UI
The ZFS page (http://<host>: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://<host>: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.
+32
View File
@@ -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
}
}
+27
View File
@@ -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
+78
View File
@@ -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 <nas@localhost>
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
+16
View File
@@ -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
+9
View File
@@ -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
+9
View File
@@ -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
+16
View File
@@ -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