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>
This commit is contained in:
2026-10-02 22:48:31 +03:00
co-authored by Claude Opus 5.5
parent 78215ac100
commit b331bc736d
10 changed files with 183 additions and 37 deletions
+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"} {
+37 -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 {
@@ -210,7 +230,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 +239,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 +295,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
}
+18 -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")
+4 -3
View File
@@ -47,7 +47,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 +58,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}}
@@ -118,7 +119,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 +131,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
}