Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b965d2c9db | ||
|
|
6362f1b9f7 | ||
|
|
b331bc736d |
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"> </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"> </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:<name> *</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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user