3 Commits
Author SHA1 Message Date
a.gritsenkoandClaude Opus 5.5 b965d2c9db SMB: optional legacy mode (SMB1 + NTLMv1) for old clients
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>
2026-10-02 23:59:53 +03:00
a.gritsenkoandClaude Opus 5.5 6362f1b9f7 SMB: map to guest = Never when no share allows guests
build / build (push) Successful in 1m2s
With "Bad User" an unknown account (the Windows login) silently became a
guest session; Windows 10/11 refuse guest sessions and report "network
path not found" instead of asking for a password, so password-only shares
were unreachable from Explorer. Map to guest only while a guest share
exists, and document the net use workaround on the shares page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:50:12 +03:00
a.gritsenkoandClaude Opus 5.5 b331bc736d Shares: allow any folder, not only ZFS datasets
build / build (push) Successful in 1m7s
SMB shares and NFS exports may now point to any existing directory
(USB disks under /mnt or /media, /srv, home folders...) instead of only
ZFS dataset mountpoints. System trees (/etc, /usr, /var, /boot, /proc,
/root, ...) are refused, also after resolving symlinks. The path
suggestions list mounted data filesystems from /proc/self/mounts in
addition to ZFS mountpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:48:31 +03:00
11 changed files with 249 additions and 41 deletions
+1
View File
@@ -28,6 +28,7 @@ type SMBConfig struct {
Enabled bool `json:"enabled"`
Workgroup string `json:"workgroup"`
ServerString string `json:"server_string"`
Legacy bool `json:"legacy"` // allow SMB1 + NTLMv1 (IPMI/BMC virtual media, old devices)
Shares []SMBShare `json:"shares"`
}
+55
View File
@@ -0,0 +1,55 @@
package shares
import (
"sort"
"strconv"
"strings"
)
// pseudoFS are filesystem types that never hold user data.
var pseudoFS = map[string]bool{
"proc": true, "sysfs": true, "tmpfs": true, "devtmpfs": true, "devpts": true, "cgroup": true, "cgroup2": true,
"securityfs": true, "pstore": true, "bpf": true, "debugfs": true, "tracefs": true, "configfs": true,
"fusectl": true, "mqueue": true, "hugetlbfs": true, "autofs": true, "binfmt_misc": true, "nsfs": true,
"overlay": true, "squashfs": true, "efivarfs": true, "rpc_pipefs": true, "nfsd": true, "ramfs": true,
}
// ParseMounts extracts shareable mountpoints from /proc/self/mounts content:
// real filesystems outside system directories, sorted and de-duplicated.
func ParseMounts(data string) []string {
seen := map[string]bool{}
var out []string
for _, line := range strings.Split(data, "\n") {
f := strings.Fields(line)
if len(f) < 3 || pseudoFS[f[2]] {
continue
}
mp := unescapeMount(f[1])
if !strings.HasPrefix(mp, "/") || ValidateNotSystem(mp) != nil || seen[mp] {
continue
}
seen[mp] = true
out = append(out, mp)
}
sort.Strings(out)
return out
}
// unescapeMount decodes the octal escapes (\040 etc.) the kernel uses in mounts.
func unescapeMount(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+4 <= len(s) {
if n, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
b.WriteByte(byte(n))
i += 3
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
+25 -3
View File
@@ -15,20 +15,42 @@ func yesno(b bool) string {
return "no"
}
func anyGuest(c Config) bool {
for _, s := range c.SMB.Shares {
if s.Guest {
return true
}
}
return false
}
// 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)
// Bad User turns unknown accounts (e.g. the Windows login) into guests.
// Without guest shares that only yields a guest session Windows refuses
// with a misleading "network path not found" instead of a password prompt.
mapGuest := "Never"
if anyGuest(c) {
mapGuest = "Bad User"
}
minProto := "server min protocol = SMB2"
if c.SMB.Legacy {
// old embedded clients (e.g. Supermicro BMC virtual media) only offer
// SMB1 "NT LM 0.12" and authenticate with NTLMv1
minProto = "server min protocol = NT1\n ntlm auth = ntlmv1-permitted"
}
g := []string{
"workgroup = " + c.SMB.Workgroup,
"server string = " + c.SMB.ServerString,
"server role = standalone server",
"security = user",
"map to guest = Bad User",
"map to guest = " + mapGuest,
"guest account = nobody",
"server min protocol = SMB2",
minProto,
"unix charset = UTF-8",
"logging = file",
"log file = /var/log/samba/log.%m",
@@ -102,7 +124,7 @@ func RenderExports(c Config, uid, gid string) string {
default:
opts = append(opts, "root_squash", "anonuid="+uid, "anongid="+gid)
}
// crossmnt: child ZFS datasets are separate mounts and must be reachable
// crossmnt: child mounts (ZFS datasets or other filesystems) must be reachable
opts = append(opts, "crossmnt", "no_subtree_check")
var parts []string
for _, cl := range e.Clients {
+40 -2
View File
@@ -40,6 +40,19 @@ func TestRenderSMB(t *testing.T) {
if strings.Count(out, "\n veto files = ") != 1 {
t.Errorf("veto files only where requested")
}
// without guest shares unknown users are rejected, so Windows asks for a password
c := sample()
c.SMB.Shares = c.SMB.Shares[1:]
if out := RenderSMB(c, "ansible"); !strings.Contains(out, "map to guest = Never") || strings.Contains(out, "Bad User") {
t.Errorf("user-only config must not map to guest:\n%s", out)
}
if out := RenderSMB(c, "ansible"); strings.Contains(out, "NT1") || strings.Contains(out, "ntlmv1") {
t.Errorf("SMB1 enabled without the legacy flag")
}
c.SMB.Legacy = true
if out := RenderSMB(c, "ansible"); !strings.Contains(out, "\n server min protocol = NT1\n ntlm auth = ntlmv1-permitted\n") || strings.Contains(out, "SMB2") {
t.Errorf("legacy mode:\n%s", out)
}
}
func TestRenderExports(t *testing.T) {
@@ -70,12 +83,13 @@ func TestValidation(t *testing.T) {
t.Errorf("%q accepted", n)
}
}
for _, p := range []string{"/nas/x", "/tmp_pool"} {
for _, p := range []string{"/nas/x", "/tmp_pool", "/mnt/usb", "/srv/media", "/home/alice/docs", "/media/disk", "/variable", "/etcetera"} {
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"} {
for _, p := range []string{"", "/", "nas/x", "/nas/../etc", "/nas/", "/nas/x\"y", "/nas/x\n",
"/etc", "/etc/samba", "/var/lib/docker", "/usr", "/proc/1", "/root", "/boot/efi", "/run/user/1000"} {
if ValidatePath(p) == nil {
t.Errorf("%q accepted", p)
}
@@ -336,3 +350,27 @@ func TestUsers(t *testing.T) {
t.Errorf("owner account deleted")
}
}
func TestParseMounts(t *testing.T) {
in := `proc /proc proc rw,nosuid 0 0
tmpfs /run tmpfs rw 0 0
/dev/sda2 / ext4 rw 0 0
/dev/sda1 /boot/efi vfat rw 0 0
tank /tank zfs rw,xattr 0 0
tank /tank zfs rw,xattr 0 0
/dev/sdc1 /mnt/usb\040disk ext4 rw 0 0
overlay /srv/docker/merged overlay rw 0 0
`
got := strings.Join(ParseMounts(in), "|")
if got != "/mnt/usb disk|/tank" {
t.Errorf("ParseMounts = %q", got)
}
}
func TestConfigRefusesSystemPath(t *testing.T) {
c := Defaults()
c.SMB.Shares = []SMBShare{{Name: "etc", Path: "/etc", Guest: true}}
if c.Validate() == nil {
t.Errorf("share on /etc accepted")
}
}
+18 -6
View File
@@ -54,8 +54,23 @@ func ValidateComment(s string) error {
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).
// systemRoots are OS trees that must never be shared.
var systemRoots = []string{"/etc", "/proc", "/sys", "/dev", "/boot", "/run", "/usr", "/bin", "/sbin",
"/lib", "/lib32", "/lib64", "/libx32", "/var", "/root", "/snap", "/lost+found"}
// ValidateNotSystem refuses the root filesystem and system directories.
func ValidateNotSystem(p string) error {
if p == "/" {
return errors.New("refusing to share the root filesystem")
}
if UnderRoots(p, systemRoots) {
return fmt.Errorf("%s is a system directory and cannot be shared", p)
}
return nil
}
// ValidatePath accepts an absolute, clean, non-system path without control
// characters. Existence and symlinks are checked by the caller.
func ValidatePath(p string) error {
if p == "" {
return errors.New("path is required")
@@ -63,10 +78,7 @@ func ValidatePath(p string) error {
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
return ValidateNotSystem(p)
}
// UnderRoots reports whether p equals or lies below one of the roots.
+7 -2
View File
@@ -9,6 +9,7 @@ import (
"log"
"math"
"net/http"
"path/filepath"
"strings"
"sync"
"time"
@@ -38,7 +39,10 @@ type Server struct {
pages map[string]*template.Template
mux *http.ServeMux
dirExists func(string) bool // share path check; replaced in tests
// share path checks; replaced in tests
dirExists func(string) bool
realPath func(string) (string, error)
readMounts func() string
mu sync.Mutex
shareErr string // last failed Apply of the share config
@@ -47,7 +51,8 @@ type Server struct {
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()}
admin: zfsadm.New(zfsadm.NoInput(run)), shares: sh, shm: shares.New(run),
dirExists: statDir, realPath: filepath.EvalSymlinks, readMounts: procMounts, 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"} {
+38 -19
View File
@@ -53,21 +53,39 @@ func (s *Server) setApplyErr(e string) {
s.mu.Unlock()
}
// mountpoints lists dataset mountpoints — the only places shares may point to.
func mountpoints(st sched.Status) []string {
// 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
for _, d := range st.Datasets {
if strings.HasPrefix(d.Mountpoint, "/") {
out = append(out, d.Mountpoint)
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: mountpoints(st), Owner: s.shm.Owner, Host: st.Host,
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
@@ -147,17 +165,22 @@ func (s *Server) sharesDone(w http.ResponseWriter, r *http.Request, msg, anchor
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 {
// 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 !shares.UnderRoots(p, mountpoints(st)) {
return errPathOutside
}
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
}
@@ -170,10 +193,7 @@ 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")
)
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 {
@@ -184,6 +204,7 @@ func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) {
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
@@ -210,7 +231,6 @@ func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) {
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 {
@@ -220,7 +240,7 @@ func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return
}
if err := s.pathOK(st, sh.Path); err != nil {
if err := s.pathOK(sh.Path); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return
}
@@ -276,13 +296,12 @@ func (s *Server) nfsSave(w http.ResponseWriter, r *http.Request) {
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 {
if err := s.pathOK(e.Path); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return
}
+39 -2
View File
@@ -68,7 +68,7 @@ func TestSharesSMBFlow(t *testing.T) {
// 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"} {
for _, want := range []string{`value="/tank/data"`, `value="data"`, `value="alice"`, "No SMB shares yet", `<option value="/mnt/usb">`} {
if !strings.Contains(body, want) {
t.Errorf("shares page missing %q", want)
}
@@ -96,10 +96,14 @@ func TestSharesSMBFlow(t *testing.T) {
t.Errorf("share not listed")
}
// refused: duplicate name, path outside pools, non-guest without users, reserved name, unclean path
// refused: duplicate name, system dirs, symlink into a system dir, missing dir,
// 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": {"logs"}, "path": {"/var/log"}, "guest": {"1"}},
{"name": {"evil"}, "path": {"/srv/evil"}, "guest": {"1"}},
{"name": {"nowhere"}, "path": {"/opt/nowhere"}, "guest": {"1"}},
{"name": {"priv"}, "path": {"/tank/data"}},
{"name": {"global"}, "path": {"/tank/data"}, "guest": {"1"}},
{"name": {"gone"}, "path": {"/tank/nope/.."}, "guest": {"1"}},
@@ -125,6 +129,12 @@ func TestSharesSMBFlow(t *testing.T) {
if !strings.Contains(f.written(shares.DefaultSMBConf), "valid users = alice") {
t.Errorf("smb.conf not regenerated after edit")
}
// a plain folder outside any ZFS dataset
rec = postForm(srv, "/shares/smb/save", url.Values{"name": {"media"}, "path": {"/srv/media"}, "guest": {"1"}, "browseable": {"1"}})
if rec.Code != http.StatusSeeOther || !strings.Contains(f.written(shares.DefaultSMBConf), "path = /srv/media") {
t.Fatalf("non-ZFS share: %d %s", rec.Code, rec.Body.String())
}
postForm(srv, "/shares/smb/delete", url.Values{"name": {"media"}})
// 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") {
@@ -192,11 +202,17 @@ func TestSharesNFSFlow(t *testing.T) {
{"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
{"path": {"/etc"}, "clients": {"*"}, "squash": {"root"}}, // system dir
} {
if rec := postForm(srv, "/shares/nfs/save", form); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") {
t.Errorf("form %v accepted", form)
}
}
if rec := postForm(srv, "/shares/nfs/save", url.Values{"path": {"/srv/media"}, "clients": {"*"}, "squash": {"root"}}); rec.Code != http.StatusSeeOther ||
!strings.Contains(f.written(shares.DefaultExports), `"/srv/media" *(`) {
t.Fatalf("non-ZFS export: %d %s", rec.Code, rec.Body.String())
}
postForm(srv, "/shares/nfs/delete", url.Values{"path": {"/srv/media"}})
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")
@@ -210,3 +226,24 @@ func TestSharesNFSFlow(t *testing.T) {
t.Errorf("delete: %d %v", rec.Code, f.calls)
}
}
func TestSharesSMBLegacySetting(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &shareRunner{users: "alice:1001:\n"}
withShareRunner(srv, f)
postForm(srv, "/shares/smb/save", url.Values{"name": {"iso"}, "path": {"/tank/data"}, "users": {"alice"}, "browseable": {"1"}})
rec := postForm(srv, "/shares/smb/settings", url.Values{"enabled": {"1"}, "workgroup": {"workgroup"}, "server_string": {"NAS"}, "legacy": {"1"}})
if rec.Code != http.StatusSeeOther || !srv.shares.Get().SMB.Legacy {
t.Fatalf("legacy not saved: %d %s", rec.Code, rec.Body.String())
}
if conf := f.written(shares.DefaultSMBConf); !strings.Contains(conf, "server min protocol = NT1") {
t.Errorf("smb.conf without SMB1:\n%s", conf)
}
if !strings.Contains(get(srv, "/shares").Body.String(), `name="legacy" value="1" checked`) {
t.Errorf("legacy checkbox not shown as checked")
}
postForm(srv, "/shares/smb/settings", url.Values{"enabled": {"1"}, "workgroup": {"WORKGROUP"}, "server_string": {"NAS"}})
if srv.shares.Get().SMB.Legacy || !strings.Contains(f.written(shares.DefaultSMBConf), "server min protocol = SMB2") {
t.Errorf("legacy not cleared")
}
}
+7 -4
View File
@@ -18,6 +18,7 @@
<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>
<label class="check"><input type="checkbox" name="legacy" value="1" {{if .C.SMB.Legacy}}checked{{end}}> Allow legacy clients (SMB1 + NTLMv1) — IPMI/BMC virtual media, old devices; less secure</label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Save SMB settings</button></span></div>
</form>
@@ -47,7 +48,7 @@
<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>Path <input name="path" required list="mountpoints" value="{{.Form.Path}}" placeholder="/nas_pool_1_8_gb/documents or /mnt/usb"></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>
@@ -58,6 +59,7 @@
<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">Path: any folder on the NAS — a ZFS dataset, a USB disk under /mnt or /media, /srv… System directories (/etc, /usr, /var…) are refused.</p>
<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}}
@@ -79,7 +81,8 @@
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Add user / set password</button></span></div>
</form>
<p class="muted small">Windows: <span class="mono">\\{{.Host}}\share</span> in Explorer, or map a network drive. macOS: Finder → Go → Connect to Server → <span class="mono">smb://{{.Host}}/share</span>.
Note: Windows 10/11 Pro/Enterprise refuse <i>guest</i> shares by default (error 0x80070035 «network path not found») — use an SMB user with a password, or allow guest access on the PC (gpedit: Lanman Workstation → Enable insecure guest logons).</p>
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).
If a password share still gives 0x80070035, connect explicitly: <span class="mono">net use \\{{.Host}}\share /user:&lt;name&gt; *</span> (Windows keeps one login per server — run <span class="mono">net use * /delete</span> first).</p>
</section>
<section class="card" id="nfs">
@@ -118,7 +121,7 @@
<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>Path <input name="path" required list="mountpoints" value="{{.NForm.Path}}" placeholder="/nas_pool_1_8_gb/documents or /mnt/usb"></label>
<label>Clients <input name="clients" required value="{{.NForm.Clients}}" placeholder="192.168.101.0/24, pc.lan, *"></label>
<label>Squash
<select name="squash">
@@ -130,7 +133,7 @@
<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>
<p class="small muted">Filesystems mounted below the path (nested datasets, disks) 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>
+12 -1
View File
@@ -89,7 +89,18 @@ func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
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") }
srv.dirExists = func(p string) bool {
return strings.HasPrefix(p, "/tank") || strings.HasPrefix(p, "/tmp_pool") || strings.HasPrefix(p, "/srv")
}
srv.realPath = func(p string) (string, error) {
if p == "/srv/evil" { // symlink into a system directory
return "/etc", nil
}
return p, nil
}
srv.readMounts = func() string {
return "proc /proc proc rw 0 0\n/dev/sda2 / ext4 rw 0 0\ntank/data /tank/data zfs rw 0 0\n/dev/sdc1 /mnt/usb ext4 rw 0 0\n"
}
return srv, sc, st
}
+7 -2
View File
@@ -29,11 +29,16 @@ nasmon has no authentication: keep port 8090 on the LAN only.
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
squash). A share can point to any existing folder (ZFS dataset, USB disk under
/mnt or /media, /srv…) except system directories (/etc, /usr, /var, /boot…),
also after resolving symlinks. 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.
enabled while at least one share exists and disabled otherwise. "Allow legacy
clients" in the SMB settings turns on SMB1 + NTLMv1 (server min protocol = NT1)
for IPMI/BMC virtual media and other old devices; it is off by default.
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.