Initial import: nasmon health monitor, split out of home_nas

Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:24:34 +03:00
co-authored by Claude Sonnet 5
commit 09159a5f31
61 changed files with 7882 additions and 0 deletions
+372
View File
@@ -0,0 +1,372 @@
package web
import (
"log"
"net/http"
"net/url"
"os"
"sort"
"strings"
"nasmon/internal/collect"
"nasmon/internal/sched"
"nasmon/internal/shares"
)
// ---- /shares: SMB + NFS management
type smbForm struct {
Orig, Name, Path, Comment string
ReadOnly, Guest, Browseable, HideSys bool
Users []string
New bool
}
type nfsForm struct {
Orig, Path, Clients, Squash string
ReadOnly, Async bool
New bool
}
type sharesVM struct {
C shares.Config
Svc collect.ShareServices
Users []string
Mountpoints []string
Owner string
Host string
Form smbForm
NForm nfsForm
ApplyErr string // last Apply failure: config saved but not live
Squashes []string
}
func (s *Server) applyErr() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.shareErr
}
func (s *Server) setApplyErr(e string) {
s.mu.Lock()
s.shareErr = e
s.mu.Unlock()
}
// mountpoints lists dataset mountpoints — the only places shares may point to.
func mountpoints(st sched.Status) []string {
var out []string
for _, d := range st.Datasets {
if strings.HasPrefix(d.Mountpoint, "/") {
out = append(out, d.Mountpoint)
}
}
sort.Strings(out)
return out
}
func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) sharesVM {
c := s.shares.Get()
vm := sharesVM{C: c, Svc: st.Services, Mountpoints: mountpoints(st), Owner: s.shm.Owner, Host: st.Host,
Form: form, NForm: nform, ApplyErr: s.applyErr(), Squashes: []string{"root", "all", "none"}}
if vm.Form.Name == "" && vm.Form.Orig == "" {
vm.Form.New, vm.Form.Guest, vm.Form.Browseable, vm.Form.HideSys = true, true, true, true
}
if vm.NForm.Path == "" && vm.NForm.Orig == "" {
vm.NForm.New = true
if vm.NForm.Squash == "" {
vm.NForm.Squash = "root"
}
}
if u, err := s.shm.ListUsers(); err == nil {
vm.Users = u
} else {
log.Printf("shares: list users: %v", err)
}
return vm
}
func smbToForm(sh shares.SMBShare) smbForm {
return smbForm{Orig: sh.Name, Name: sh.Name, Path: sh.Path, Comment: sh.Comment, ReadOnly: sh.ReadOnly,
Guest: sh.Guest, Browseable: sh.Browseable, HideSys: sh.HideSysFiles, Users: sh.Users}
}
func nfsToForm(e shares.NFSExport) nfsForm {
return nfsForm{Orig: e.Path, Path: e.Path, Clients: strings.Join(e.Clients, ", "), Squash: e.Squash, ReadOnly: e.ReadOnly, Async: e.Async}
}
func (s *Server) sharesPage(w http.ResponseWriter, r *http.Request) {
st := s.sch.Status()
c := s.shares.Get()
q := r.URL.Query()
var form smbForm
var nform nfsForm
if i := c.FindSMB(q.Get("edit")); i >= 0 {
form = smbToForm(c.SMB.Shares[i])
}
if i := c.FindNFS(q.Get("nedit")); i >= 0 {
nform = nfsToForm(c.NFS.Exports[i])
}
if p := q.Get("path"); p != "" { // "share…" link from the ZFS page
if form.Orig == "" {
form.Path = p
form.Name = strings.Trim(p[strings.LastIndex(p, "/")+1:], "/")
}
if nform.Orig == "" {
nform.Path = p
}
}
s.render(w, r, "shares", pageData{Title: "Shares", Data: s.sharesView(st, form, nform), Alerts: len(st.Alerts), Flash: q.Get("msg")})
}
// saveAndApply persists the config and pushes it to the daemons. Returns the
// message for the user and whether everything went live.
func (s *Server) saveAndApply(c shares.Config) error {
if err := s.shares.Save(c); err != nil {
return err
}
if err := s.shm.Apply(c); err != nil {
s.setApplyErr(err.Error())
log.Printf("shares: apply failed: %v", err)
return nil
}
s.setApplyErr("")
s.sch.RefreshStorage()
return nil
}
func (s *Server) sharesFail(w http.ResponseWriter, r *http.Request, msg string, form smbForm, nform nfsForm) {
st := s.sch.Status()
s.render(w, r, "shares", pageData{Title: "Shares", Data: s.sharesView(st, form, nform), Alerts: len(st.Alerts), Error: msg})
}
func (s *Server) sharesDone(w http.ResponseWriter, r *http.Request, msg, anchor string) {
if e := s.applyErr(); e != "" {
msg += " — but applying failed: " + e
}
http.Redirect(w, r, "/shares?msg="+url.QueryEscape(msg)+anchor, http.StatusSeeOther)
}
// pathOK checks that a share path exists and lies inside a ZFS dataset.
func (s *Server) pathOK(st sched.Status, p string) error {
if err := shares.ValidatePath(p); err != nil {
return err
}
if !shares.UnderRoots(p, mountpoints(st)) {
return errPathOutside
}
if !s.dirExists(p) {
return errPathMissing
}
return nil
}
func statDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
type strErr string
func (e strErr) Error() string { return string(e) }
const (
errPathOutside = strErr("path must be inside a ZFS dataset (see the list of mountpoints)")
errPathMissing = strErr("path does not exist or is not a directory")
)
func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
c := s.shares.Get()
c.SMB.Enabled = r.FormValue("enabled") != ""
c.SMB.Workgroup = strings.ToUpper(strings.TrimSpace(r.FormValue("workgroup")))
c.SMB.ServerString = strings.TrimSpace(r.FormValue("server_string"))
if err := shares.ValidateComment(c.SMB.ServerString); err != nil {
s.sharesFail(w, r, "Not saved: server description: "+err.Error(), smbForm{}, nfsForm{})
return
}
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, nfsForm{})
return
}
s.sharesDone(w, r, "SMB settings saved", "#smb")
}
func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f := smbForm{
Orig: strings.TrimSpace(r.FormValue("orig")), Name: strings.TrimSpace(r.FormValue("name")),
Path: strings.TrimSpace(r.FormValue("path")), Comment: strings.TrimSpace(r.FormValue("comment")),
ReadOnly: r.FormValue("read_only") != "", Guest: r.FormValue("guest") != "",
Browseable: r.FormValue("browseable") != "", HideSys: r.FormValue("hide_sys") != "",
Users: r.Form["users"],
}
if len(f.Path) > 1 {
f.Path = strings.TrimRight(f.Path, "/")
}
st := s.sch.Status()
sh := shares.SMBShare{Name: f.Name, Path: f.Path, Comment: f.Comment, ReadOnly: f.ReadOnly, Guest: f.Guest,
Users: f.Users, Browseable: f.Browseable, HideSysFiles: f.HideSys}
if sh.Guest {
sh.Users = nil
}
if err := sh.Validate(); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return
}
if err := s.pathOK(st, sh.Path); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return
}
c := s.shares.Get()
if i := c.FindSMB(f.Orig); f.Orig != "" && i >= 0 {
if sh.Name != f.Orig && c.FindSMB(sh.Name) >= 0 {
s.sharesFail(w, r, "Not saved: share "+sh.Name+" already exists", f, nfsForm{})
return
}
c.SMB.Shares[i] = sh
} else {
if c.FindSMB(sh.Name) >= 0 {
s.sharesFail(w, r, "Not saved: share "+sh.Name+" already exists — edit it instead", f, nfsForm{})
return
}
c.SMB.Shares = append(c.SMB.Shares, sh)
}
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return
}
log.Printf("shares: SMB share %s -> %s saved (guest=%v users=%v ro=%v)", sh.Name, sh.Path, sh.Guest, sh.Users, sh.ReadOnly)
s.sharesDone(w, r, "Share "+sh.Name+" saved", "#smb")
}
func (s *Server) smbDelete(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
c := s.shares.Get()
i := c.FindSMB(name)
if i < 0 {
s.sharesFail(w, r, "No such share: "+name, smbForm{}, nfsForm{})
return
}
c.SMB.Shares = append(c.SMB.Shares[:i], c.SMB.Shares[i+1:]...)
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not deleted: "+err.Error(), smbForm{}, nfsForm{})
return
}
log.Printf("shares: SMB share %s deleted", name)
s.sharesDone(w, r, "Share "+name+" deleted", "#smb")
}
func (s *Server) nfsSave(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f := nfsForm{
Orig: strings.TrimSpace(r.FormValue("orig")), Path: strings.TrimSpace(r.FormValue("path")),
Clients: strings.TrimSpace(r.FormValue("clients")), Squash: r.FormValue("squash"),
ReadOnly: r.FormValue("read_only") != "", Async: r.FormValue("async") != "",
}
if len(f.Path) > 1 {
f.Path = strings.TrimRight(f.Path, "/")
}
st := s.sch.Status()
e := shares.NFSExport{Path: f.Path, Clients: shares.SplitList(f.Clients), ReadOnly: f.ReadOnly, Squash: f.Squash, Async: f.Async}
if err := e.Validate(); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return
}
if err := s.pathOK(st, e.Path); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return
}
c := s.shares.Get()
if i := c.FindNFS(f.Orig); f.Orig != "" && i >= 0 {
if e.Path != f.Orig && c.FindNFS(e.Path) >= 0 {
s.sharesFail(w, r, "Not saved: "+e.Path+" is already exported", smbForm{}, f)
return
}
c.NFS.Exports[i] = e
} else {
if c.FindNFS(e.Path) >= 0 {
s.sharesFail(w, r, "Not saved: "+e.Path+" is already exported — edit it instead", smbForm{}, f)
return
}
c.NFS.Exports = append(c.NFS.Exports, e)
}
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return
}
log.Printf("shares: NFS export %s to %v saved (ro=%v squash=%s)", e.Path, e.Clients, e.ReadOnly, e.Squash)
s.sharesDone(w, r, "Export "+e.Path+" saved", "#nfs")
}
func (s *Server) nfsDelete(w http.ResponseWriter, r *http.Request) {
p := strings.TrimSpace(r.FormValue("path"))
c := s.shares.Get()
i := c.FindNFS(p)
if i < 0 {
s.sharesFail(w, r, "No such export: "+p, smbForm{}, nfsForm{})
return
}
c.NFS.Exports = append(c.NFS.Exports[:i], c.NFS.Exports[i+1:]...)
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not deleted: "+err.Error(), smbForm{}, nfsForm{})
return
}
log.Printf("shares: NFS export %s deleted", p)
s.sharesDone(w, r, "Export "+p+" deleted", "#nfs")
}
func (s *Server) nfsSettings(w http.ResponseWriter, r *http.Request) {
c := s.shares.Get()
c.NFS.Enabled = r.FormValue("enabled") != ""
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, nfsForm{})
return
}
s.sharesDone(w, r, "NFS settings saved", "#nfs")
}
func (s *Server) userSave(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
pass := r.FormValue("password")
if err := s.shm.SetPassword(name, pass); err != nil {
s.sharesFail(w, r, "User not saved: "+err.Error(), smbForm{}, nfsForm{})
return
}
http.Redirect(w, r, "/shares?msg="+url.QueryEscape("Password set for "+name)+"#users", http.StatusSeeOther)
}
func (s *Server) userDelete(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
c := s.shares.Get()
for _, sh := range c.SMB.Shares {
for _, u := range sh.Users {
if u == name {
s.sharesFail(w, r, "User "+name+" is still allowed on share "+sh.Name+" — remove them there first", smbForm{}, nfsForm{})
return
}
}
}
if err := s.shm.DeleteUser(name); err != nil {
s.sharesFail(w, r, "User not deleted: "+err.Error(), smbForm{}, nfsForm{})
return
}
http.Redirect(w, r, "/shares?msg="+url.QueryEscape("User "+name+" deleted")+"#users", http.StatusSeeOther)
}
func (s *Server) sharesApply(w http.ResponseWriter, r *http.Request) {
if err := s.saveAndApply(s.shares.Get()); err != nil {
s.sharesFail(w, r, err.Error(), smbForm{}, nfsForm{})
return
}
s.sharesDone(w, r, "Configuration applied", "")
}