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:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/nasmon
|
||||
/nasmon.exe
|
||||
dist/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// Package store persists SMART snapshots, alert state and self-test bookkeeping in SQLite.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"nasmon/internal/collect"
|
||||
)
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS smart_snapshots (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts INTEGER NOT NULL,
|
||||
serial TEXT NOT NULL,
|
||||
device TEXT NOT NULL,
|
||||
temp_c INTEGER,
|
||||
healthy INTEGER,
|
||||
power_on_hours INTEGER,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS smart_snapshots_serial_ts ON smart_snapshots(serial, ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
opened_at INTEGER NOT NULL,
|
||||
resolved_at INTEGER,
|
||||
last_notified_at INTEGER,
|
||||
acked_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS alerts_key_open ON alerts(key, resolved_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS selftest_runs (
|
||||
serial TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (serial, kind)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disks_seen (
|
||||
serial TEXT PRIMARY KEY,
|
||||
model TEXT NOT NULL,
|
||||
device TEXT NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
missing_acked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// migrate adds columns introduced after the first release.
|
||||
func migrate(db *sql.DB) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(alerts)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
have := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
have[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !have["acked_at"] {
|
||||
if _, err := db.Exec(`ALTER TABLE alerts ADD COLUMN acked_at INTEGER`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// ---- SMART snapshots
|
||||
|
||||
func (s *Store) AddSmart(sm collect.Smart) error {
|
||||
if sm.Serial == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(sm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO smart_snapshots(ts, serial, device, temp_c, healthy, power_on_hours, data) VALUES(?,?,?,?,?,?,?)`,
|
||||
sm.CheckedAt.Unix(), sm.Serial, sm.Device, sm.TempC, sm.Healthy, sm.PowerOnHrs, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
// LastSmart returns the most recent stored snapshot for a serial.
|
||||
func (s *Store) LastSmart(serial string) (*collect.Smart, error) {
|
||||
var data string
|
||||
err := s.db.QueryRow(`SELECT data FROM smart_snapshots WHERE serial=? ORDER BY ts DESC LIMIT 1`, serial).Scan(&data)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sm collect.Smart
|
||||
if err := json.Unmarshal([]byte(data), &sm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sm, nil
|
||||
}
|
||||
|
||||
type SmartPoint struct {
|
||||
T int64 `json:"t"`
|
||||
TempC int `json:"temp_c"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Hours int64 `json:"hours"`
|
||||
Realloc int64 `json:"realloc"` // attr 5 raw or NVMe media errors
|
||||
Pending int64 `json:"pending"` // attr 197 raw
|
||||
CRC int64 `json:"crc"` // attr 199 raw
|
||||
}
|
||||
|
||||
// SmartHistory returns compact per-snapshot points since t.
|
||||
func (s *Store) SmartHistory(serial string, since time.Time) ([]SmartPoint, error) {
|
||||
rows, err := s.db.Query(`SELECT ts, data FROM smart_snapshots WHERE serial=? AND ts>=? ORDER BY ts`, serial, since.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SmartPoint
|
||||
for rows.Next() {
|
||||
var ts int64
|
||||
var data string
|
||||
if err := rows.Scan(&ts, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sm collect.Smart
|
||||
if json.Unmarshal([]byte(data), &sm) != nil {
|
||||
continue
|
||||
}
|
||||
p := SmartPoint{T: ts, TempC: sm.TempC, Healthy: sm.Healthy, Hours: sm.PowerOnHrs}
|
||||
if a, ok := sm.Attr(5); ok {
|
||||
p.Realloc = a.Raw
|
||||
} else if sm.NVMe != nil {
|
||||
p.Realloc = sm.NVMe.MediaErrors
|
||||
}
|
||||
if a, ok := sm.Attr(197); ok {
|
||||
p.Pending = a.Raw
|
||||
}
|
||||
if a, ok := sm.Attr(199); ok {
|
||||
p.CRC = a.Raw
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) PruneSmart(olderThan time.Time) error {
|
||||
_, err := s.db.Exec(`DELETE FROM smart_snapshots WHERE ts<?`, 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, ¬ified, &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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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", "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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">×</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">×</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">× on an open alert acknowledges it: it leaves the dashboard and stops e-mail reminders until the problem changes (higher level, grown counters) or resolves by itself. × on a resolved alert removes it from the log; resolved alerts older than 90 days are removed automatically.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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">×</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}}
|
||||
@@ -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 & send test e-mail</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -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"> </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"> </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"> </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}}
|
||||
@@ -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}}/<name> (parents are created too), mounts it at /{{.Name}}/<name> 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}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user