build / build (push) Successful in 1m1s
Supermicro BMC virtual media ("CD-ROM image" over a Windows share) only
offers the SMB1 dialect "NT LM 0.12", which Samba rejects with
server min protocol = SMB2. Add an opt-in "Allow legacy clients" SMB
setting that renders server min protocol = NT1 and
ntlm auth = ntlmv1-permitted. Off by default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
392 lines
12 KiB
Go
392 lines
12 KiB
Go
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()
|
|
}
|
|
|
|
// pathSuggestions lists ZFS dataset mountpoints plus other mounted data
|
|
// filesystems (USB disks, /mnt, /srv…) for the path input. Any non-system
|
|
// folder may be shared; these are only hints.
|
|
func (s *Server) pathSuggestions(st sched.Status) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
add := func(p string) {
|
|
if strings.HasPrefix(p, "/") && !seen[p] && shares.ValidateNotSystem(p) == nil {
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
for _, d := range st.Datasets {
|
|
add(d.Mountpoint)
|
|
}
|
|
for _, p := range shares.ParseMounts(s.readMounts()) {
|
|
add(p)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func procMounts() string {
|
|
b, err := os.ReadFile("/proc/self/mounts")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) sharesVM {
|
|
c := s.shares.Get()
|
|
vm := sharesVM{C: c, Svc: st.Services, Mountpoints: s.pathSuggestions(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 is an existing, non-system directory, also
|
|
// after resolving symlinks (so /tank/link -> /etc is refused).
|
|
func (s *Server) pathOK(p string) error {
|
|
if err := shares.ValidatePath(p); err != nil {
|
|
return err
|
|
}
|
|
if !s.dirExists(p) {
|
|
return errPathMissing
|
|
}
|
|
rp, err := s.realPath(p)
|
|
if err != nil {
|
|
return errPathMissing
|
|
}
|
|
if rp != p && shares.ValidateNotSystem(rp) != nil {
|
|
return strErr("path resolves to " + rp + ", a system directory")
|
|
}
|
|
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 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"))
|
|
c.SMB.Legacy = r.FormValue("legacy") != ""
|
|
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, "/")
|
|
}
|
|
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(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, "/")
|
|
}
|
|
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(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", "")
|
|
}
|