9 Commits
Author SHA1 Message Date
a.gritsenkoandClaude Opus 5.5 42cfb43e2e Remove the RepoISO integration; keep generic support for shares of other services
build / build (push) Successful in 1m5s
RepoISO now manages the NAS itself, so nasmon no longer polls it (settings field, dashboard card,
menu link and repoiso:* alerts are gone). Drop-in SMB includes, read-only listing and keeping
smbd/nfs-server running for other software's shares stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:05:16 +03:00
a.gritsenkoandClaude Opus 5.5 30dbb00dd1 Shares of other services and RepoISO integration
build / build (push) Successful in 1m43s
- include SMB drop-ins from /etc/samba/smb.conf.d into the generated smb.conf
- keep smbd/nfs-server running while other services (/etc/exports.d, drop-ins) share,
  instead of disabling them when nasmon has no shares of its own
- list those shares read-only on /shares
- settings: RepoISO URL; poll its /api/integration/status, dashboard card, menu link,
  alerts for portal down, stale worker, failed jobs, unmounted publications

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:06:17 +03:00
a.gritsenkoandClaude Opus 5.5 661a862f8d Revert automatic releases on every push to main
build / build (push) Successful in 1m1s
Releases are published only for manually pushed v* tags again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:33:35 +03:00
a.gritsenkoandClaude Opus 5.5 0eab036de7 README: document automatic releases
build / build (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:31:24 +03:00
a.gritsenkoandClaude Opus 5.5 4a03d58193 CI: release every push to main with the next patch tag
build / build (push) Successful in 1m3s
Pushes to main get the next vX.Y.(Z+1) tag, are built with that version and
published as a Gitea release with the deb/rpm and commit subjects as notes.
Manual v* tags still release that exact version; already attached assets are
skipped so a tag-triggered rerun does not duplicate them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:29:51 +03:00
a.gritsenkoandClaude Opus 5.5 0cdee29359 ZFS: dedup toggle per pool and dedup option for new datasets
build / build (push) Successful in 1m4s
Pool cards show the dedup ratio and can set dedup=on|off on the pool root
dataset (inherited by children); the new-dataset form gets a dedup option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:30:38 +03:00
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
22 changed files with 804 additions and 66 deletions
+4
View File
@@ -11,6 +11,10 @@ share management from the UI.
* Service: `systemctl status nasmon` / `journalctl -u nasmon -f` * Service: `systemctl status nasmon` / `journalctl -u nasmon -f`
* JSON API: `/api/status`, `/api/history?hours=24`, `/api/disks/<serial>/history?days=30` * JSON API: `/api/status`, `/api/history?hours=24`, `/api/disks/<serial>/history?days=30`
Shares of other software on the same host (`/etc/samba/smb.conf.d/*.conf`,
`/etc/exports.d/*.exports`) are included into the generated config, listed
read-only and keep smbd / nfs-server running.
No authentication — designed for a LAN-only NAS (the `/shares` page can No authentication — designed for a LAN-only NAS (the `/shares` page can
create SMB/NFS users and exports, so keep it off the public internet). create SMB/NFS users and exports, so keep it off the public internet).
+11 -2
View File
@@ -35,6 +35,8 @@ type Pool struct {
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Action string `json:"action,omitempty"` Action string `json:"action,omitempty"`
Vdevs []Vdev `json:"vdevs"` Vdevs []Vdev `json:"vdevs"`
// DedupRatio is the pool-wide dedup ratio (1.00 = nothing deduplicated).
DedupRatio float64 `json:"dedup_ratio"`
} }
type Dataset struct { type Dataset struct {
@@ -43,11 +45,12 @@ type Dataset struct {
Avail uint64 `json:"avail"` Avail uint64 `json:"avail"`
Refer uint64 `json:"refer"` Refer uint64 `json:"refer"`
Mountpoint string `json:"mountpoint"` Mountpoint string `json:"mountpoint"`
Dedup string `json:"dedup"` // dedup property: off, on, verify, sha256, ...
} }
// ReadPools combines zpool list and zpool status for every pool. // ReadPools combines zpool list and zpool status for every pool.
func ReadPools() ([]Pool, error) { func ReadPools() ([]Pool, error) {
out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag") out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag,dedup")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -63,7 +66,7 @@ func ReadPools() ([]Pool, error) {
} }
func ReadDatasets() ([]Dataset, error) { func ReadDatasets() ([]Dataset, error) {
out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint") out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint,dedup")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -84,6 +87,9 @@ func parseZpoolList(b []byte) []Pool {
p.Free, _ = strconv.ParseUint(f[3], 10, 64) p.Free, _ = strconv.ParseUint(f[3], 10, 64)
p.CapPct = atoiPct(f[4]) p.CapPct = atoiPct(f[4])
p.FragPct = atoiPct(f[6]) p.FragPct = atoiPct(f[6])
if len(f) > 7 {
p.DedupRatio, _ = strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(f[7]), "x"), 64)
}
if p.CapPct == 0 && p.Size > 0 { if p.CapPct == 0 && p.Size > 0 {
p.CapPct = int(p.Alloc * 100 / p.Size) p.CapPct = int(p.Alloc * 100 / p.Size)
} }
@@ -220,6 +226,9 @@ func parseZfsList(b []byte) []Dataset {
d.Used, _ = strconv.ParseUint(f[1], 10, 64) d.Used, _ = strconv.ParseUint(f[1], 10, 64)
d.Avail, _ = strconv.ParseUint(f[2], 10, 64) d.Avail, _ = strconv.ParseUint(f[2], 10, 64)
d.Refer, _ = strconv.ParseUint(f[3], 10, 64) d.Refer, _ = strconv.ParseUint(f[3], 10, 64)
if len(f) > 5 {
d.Dedup = f[5]
}
out = append(out, d) out = append(out, d)
} }
return out return out
+13
View File
@@ -119,6 +119,15 @@ func TestParseZpoolList(t *testing.T) {
if pools[1].CapPct != 90 || pools[1].FragPct != 0 { if pools[1].CapPct != 90 || pools[1].FragPct != 0 {
t.Errorf("pool1 = %+v", pools[1]) t.Errorf("pool1 = %+v", pools[1])
} }
if pools[0].DedupRatio != 0 {
t.Errorf("dedup ratio without the column = %v", pools[0].DedupRatio)
}
// with the dedup column; -p prints "1.35", without -p it is "1.35x"
pools = parseZpoolList([]byte("a\t1\t1\t0\t100\tONLINE\t0\t1.35\nb\t1\t1\t0\t100\tONLINE\t0\t2.00x\n"))
if len(pools) != 2 || pools[0].DedupRatio != 1.35 || pools[1].DedupRatio != 2 {
t.Errorf("dedup ratio = %+v", pools)
}
} }
func TestParseZfsList(t *testing.T) { func TestParseZfsList(t *testing.T) {
@@ -127,4 +136,8 @@ func TestParseZfsList(t *testing.T) {
if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 { if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 {
t.Errorf("datasets = %+v", ds) t.Errorf("datasets = %+v", ds)
} }
ds = parseZfsList([]byte("tank\t1\t2\t3\t/tank\ton\n"))
if len(ds) != 1 || ds[0].Dedup != "on" || ds[0].Mountpoint != "/tank" {
t.Errorf("dedup column: %+v", ds)
}
} }
+22 -7
View File
@@ -29,12 +29,17 @@ type Manager struct {
UID, GID string UID, GID string
SMBConf string SMBConf string
Exports string Exports string
// IncludeDir holds Samba drop-ins of other services, included into smb.conf;
// ExportsDir is scanned for other services' NFS exports (both: "" = none).
IncludeDir string
ExportsDir string
Timeout time.Duration Timeout time.Duration
} }
func New(run hostrun.Runner) *Manager { func New(run hostrun.Runner) *Manager {
m := &Manager{Run: run, LookPath: exec.LookPath, UID: DefaultOwnerUID, GID: DefaultOwnerUID, m := &Manager{Run: run, LookPath: exec.LookPath, UID: DefaultOwnerUID, GID: DefaultOwnerUID,
SMBConf: DefaultSMBConf, Exports: DefaultExports, Timeout: 90 * time.Second} SMBConf: DefaultSMBConf, Exports: DefaultExports, IncludeDir: DefaultSMBIncludeDir,
ExportsDir: DefaultExportsDir, Timeout: 90 * time.Second}
m.Owner = m.UID m.Owner = m.UID
if u, err := user.LookupId(m.UID); err == nil { if u, err := user.LookupId(m.UID); err == nil {
m.Owner, m.GID = u.Username, u.Gid m.Owner, m.GID = u.Username, u.Gid
@@ -74,14 +79,19 @@ func (m *Manager) Apply(c Config) error {
func (m *Manager) applySMB(c Config) error { func (m *Manager) applySMB(c Config) error {
ctx, cancel := m.ctx() ctx, cancel := m.ctx()
defer cancel() defer cancel()
if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(c, m.Owner)); err != nil { own := c
if !c.SMB.Enabled {
own.SMB.Shares = nil // other services may keep smbd running; don't serve disabled shares
}
external := countKind(m.External(), "smb")
if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(own, m.Owner, IncludeFiles(m.IncludeDir)...)); err != nil {
return fmt.Errorf("write %s: %w", m.SMBConf, err) return fmt.Errorf("write %s: %w", m.SMBConf, err)
} }
if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil { if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil {
_ = hostrun.Restore(ctx, m.Run, m.SMBConf) _ = hostrun.Restore(ctx, m.Run, m.SMBConf)
return fmt.Errorf("testparm rejected the config, previous file restored: %s", firstLines(out, 4)) return fmt.Errorf("testparm rejected the config, previous file restored: %s", firstLines(out, 4))
} }
active := c.SMB.Enabled && len(c.SMB.Shares) > 0 active := len(own.SMB.Shares) > 0 || external > 0
if !active { if !active {
_, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd") _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd")
if err != nil { if err != nil {
@@ -96,17 +106,22 @@ func (m *Manager) applySMB(c Config) error {
if out, err := m.Run(ctx, "", "smbcontrol", "all", "reload-config"); err != nil { if out, err := m.Run(ctx, "", "smbcontrol", "all", "reload-config"); err != nil {
return fmt.Errorf("reload smbd: %s", firstLines(out, 2)) return fmt.Errorf("reload smbd: %s", firstLines(out, 2))
} }
log.Printf("shares: SMB config applied, %d shares", len(c.SMB.Shares)) log.Printf("shares: SMB config applied, %d shares, %d from other services", len(own.SMB.Shares), external)
return nil return nil
} }
func (m *Manager) applyNFS(c Config) error { func (m *Manager) applyNFS(c Config) error {
ctx, cancel := m.ctx() ctx, cancel := m.ctx()
defer cancel() defer cancel()
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(c, m.UID, m.GID)); err != nil { own := c
if !c.NFS.Enabled {
own.NFS.Exports = nil
}
external := countKind(m.External(), "nfs")
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(own, m.UID, m.GID)); err != nil {
return fmt.Errorf("write %s: %w", m.Exports, err) return fmt.Errorf("write %s: %w", m.Exports, err)
} }
active := c.NFS.Enabled && len(c.NFS.Exports) > 0 active := len(own.NFS.Exports) > 0 || external > 0
if !active { if !active {
if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil { if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil {
return fmt.Errorf("stop nfs-server: %w", err) return fmt.Errorf("stop nfs-server: %w", err)
@@ -122,7 +137,7 @@ func (m *Manager) applyNFS(c Config) error {
_, _ = m.Run(ctx, "", "exportfs", "-ra") _, _ = m.Run(ctx, "", "exportfs", "-ra")
return fmt.Errorf("exportfs rejected the exports, previous file restored: %s", firstLines(out, 4)) return fmt.Errorf("exportfs rejected the exports, previous file restored: %s", firstLines(out, 4))
} }
log.Printf("shares: NFS exports applied, %d exports", len(c.NFS.Exports)) log.Printf("shares: NFS exports applied, %d exports, %d from other services", len(own.NFS.Exports), external)
return nil return nil
} }
+128
View File
@@ -0,0 +1,128 @@
package shares
import (
"os"
"path/filepath"
"sort"
"strings"
)
// Other services on the host publish their own shares through
// drop-in files. nasmon includes the SMB drop-ins into the smb.conf it
// generates and keeps smbd / nfs-server running while any of them define a
// share, even when nasmon itself has none.
const (
DefaultSMBIncludeDir = "/etc/samba/smb.conf.d"
DefaultExportsDir = "/etc/exports.d"
)
// ExternalShare is a share defined outside nasmon (read-only in the UI).
type ExternalShare struct {
Kind string // smb | nfs
Source string // drop-in file name without extension
Name string // SMB share name (empty for NFS)
Path string
Clients string // NFS clients with options, as written in the file
}
// IncludeFiles lists the *.conf drop-ins in dir, sorted. A missing dir yields nil.
func IncludeFiles(dir string) []string {
if dir == "" {
return nil
}
m, _ := filepath.Glob(filepath.Join(dir, "*.conf"))
sort.Strings(m)
return m
}
func sourceName(file string) string {
return strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
}
// ParseSMBDropIn returns the shares ([section] other than [global], [homes],
// [printers]) defined in a Samba config fragment.
func ParseSMBDropIn(text, source string) []ExternalShare {
var out []ExternalShare
var cur *ExternalShare
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" || line[0] == '#' || line[0] == ';' {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
name := strings.TrimSpace(line[1 : len(line)-1])
switch strings.ToLower(name) {
case "global", "homes", "printers":
cur = nil
default:
out = append(out, ExternalShare{Kind: "smb", Source: source, Name: name})
cur = &out[len(out)-1]
}
continue
}
if cur == nil {
continue
}
if k, v, ok := strings.Cut(line, "="); ok && strings.EqualFold(strings.TrimSpace(k), "path") {
cur.Path = strings.TrimSpace(v)
}
}
return out
}
// ParseExportsFile returns the exports in an exports(5) file.
func ParseExportsFile(text, source string) []ExternalShare {
var out []ExternalShare
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" || line[0] == '#' {
continue
}
var path, rest string
if line[0] == '"' {
if end := strings.Index(line[1:], `"`); end >= 0 {
path, rest = line[1:end+1], line[end+2:]
}
} else {
path, rest, _ = strings.Cut(line, " ")
}
if path == "" {
continue
}
out = append(out, ExternalShare{Kind: "nfs", Source: source, Path: path, Clients: strings.TrimSpace(rest)})
}
return out
}
// External reads the SMB drop-ins and the exports.d files of other services.
func (m *Manager) External() []ExternalShare {
var out []ExternalShare
for _, f := range IncludeFiles(m.IncludeDir) {
if b, err := os.ReadFile(f); err == nil {
out = append(out, ParseSMBDropIn(string(b), sourceName(f))...)
}
}
if m.ExportsDir != "" {
files, _ := filepath.Glob(filepath.Join(m.ExportsDir, "*.exports"))
sort.Strings(files)
for _, f := range files {
if filepath.Clean(f) == filepath.Clean(m.Exports) {
continue
}
if b, err := os.ReadFile(f); err == nil {
out = append(out, ParseExportsFile(string(b), sourceName(f))...)
}
}
}
return out
}
func countKind(ext []ExternalShare, kind string) int {
n := 0
for _, e := range ext {
if e.Kind == kind {
n++
}
}
return n
}
+111
View File
@@ -0,0 +1,111 @@
package shares
import (
"os"
"path/filepath"
"strings"
"testing"
)
const repoisoSMB = `# Managed by repoiso — do not edit
[iso-pve]
path = /tank/iso/export/pve
read only = yes
guest ok = yes
[global]
; ignored
`
func writeDropIns(t *testing.T) (smbDir, nfsDir string) {
t.Helper()
root := t.TempDir()
smbDir, nfsDir = filepath.Join(root, "smb.conf.d"), filepath.Join(root, "exports.d")
for _, d := range []string{smbDir, nfsDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
must := func(p, s string) {
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
must(filepath.Join(smbDir, "repoiso.conf"), repoisoSMB)
must(filepath.Join(smbDir, "notes.txt"), "[ignored]\n")
must(filepath.Join(nfsDir, "repoiso.exports"), "# Managed by repoiso\n\"/tank/iso/export/pve\" 10.10.0.0/24(ro,root_squash)\n")
must(filepath.Join(nfsDir, "nasmon.exports"), "\"/data\" *(rw)\n")
return
}
func TestParseDropIns(t *testing.T) {
smb := ParseSMBDropIn(repoisoSMB, "repoiso")
if len(smb) != 1 || smb[0].Name != "iso-pve" || smb[0].Path != "/tank/iso/export/pve" || smb[0].Source != "repoiso" {
t.Fatalf("smb: %+v", smb)
}
nfs := ParseExportsFile("# c\n/plain host(ro)\n\"/with space\" 10.0.0.0/8(ro)\n\n", "x")
if len(nfs) != 2 || nfs[0].Path != "/plain" || nfs[1].Path != "/with space" || nfs[1].Clients != "10.0.0.0/8(ro)" {
t.Fatalf("nfs: %+v", nfs)
}
}
func TestExternalSkipsOwnExports(t *testing.T) {
smbDir, nfsDir := writeDropIns(t)
m := newManager(newFake())
m.IncludeDir, m.ExportsDir, m.Exports = smbDir, nfsDir, filepath.Join(nfsDir, "nasmon.exports")
ext := m.External()
if countKind(ext, "smb") != 1 || countKind(ext, "nfs") != 1 {
t.Fatalf("external: %+v", ext)
}
if got := IncludeFiles(smbDir); len(got) != 1 || !strings.HasSuffix(got[0], "repoiso.conf") {
t.Fatalf("includes: %v", got)
}
}
func TestRenderSMBIncludes(t *testing.T) {
out := RenderSMB(sample(), "ansible", "/etc/samba/smb.conf.d/repoiso.conf")
if !strings.HasSuffix(strings.TrimSpace(out), "include = /etc/samba/smb.conf.d/repoiso.conf") {
t.Fatalf("include not at the end:\n%s", out)
}
if strings.Contains(RenderSMB(sample(), "ansible"), "include =") {
t.Fatal("include rendered without drop-ins")
}
}
// The bug this guards against: nasmon without own shares disabled smbd and
// nfs-server and killed the shares RepoISO publishes for Proxmox.
func TestApplyKeepsServicesForOtherServices(t *testing.T) {
smbDir, nfsDir := writeDropIns(t)
f := newFake()
m := newManager(f)
m.IncludeDir, m.ExportsDir, m.Exports = smbDir, nfsDir, filepath.Join(nfsDir, "nasmon.exports")
if err := m.Apply(Defaults()); err != nil {
t.Fatal(err)
}
if f.has("systemctl disable") {
t.Fatalf("services disabled although other services share: %v", f.calls)
}
if !f.has("systemctl enable --now smbd nmbd") || !f.has("systemctl enable --now nfs-server") || !f.has("exportfs -ra") {
t.Fatalf("services not kept running: %v", f.calls)
}
wi := f.index("sh -c")
if !strings.Contains(f.stdin[f.calls[wi]], "include = "+filepath.Join(smbDir, "repoiso.conf")) {
t.Errorf("smb.conf lacks the drop-in include")
}
}
func TestApplyDisabledSMBHidesOwnShares(t *testing.T) {
smbDir, _ := writeDropIns(t)
f := newFake()
m := newManager(f)
m.IncludeDir = smbDir
c := sample()
c.SMB.Enabled = false
if err := m.Apply(c); err != nil {
t.Fatal(err)
}
if strings.Contains(f.stdin[f.calls[f.index("sh -c")]], "[documents]") {
t.Errorf("disabled nasmon shares still rendered while smbd stays up for others")
}
}
+1
View File
@@ -28,6 +28,7 @@ type SMBConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Workgroup string `json:"workgroup"` Workgroup string `json:"workgroup"`
ServerString string `json:"server_string"` ServerString string `json:"server_string"`
Legacy bool `json:"legacy"` // allow SMB1 + NTLMv1 (IPMI/BMC virtual media, old devices)
Shares []SMBShare `json:"shares"` 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()
}
+34 -5
View File
@@ -15,20 +15,43 @@ func yesno(b bool) string {
return "no" 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 // 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 // SMB session acts as (force user), so files end up with the same owner the
// containers use. // containers use. includes are drop-in files of other services appended as
func RenderSMB(c Config, owner string) string { // "include =" lines.
func RenderSMB(c Config, owner string, includes ...string) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "%s\n\n[global]\n", Header) 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{ g := []string{
"workgroup = " + c.SMB.Workgroup, "workgroup = " + c.SMB.Workgroup,
"server string = " + c.SMB.ServerString, "server string = " + c.SMB.ServerString,
"server role = standalone server", "server role = standalone server",
"security = user", "security = user",
"map to guest = Bad User", "map to guest = " + mapGuest,
"guest account = nobody", "guest account = nobody",
"server min protocol = SMB2", minProto,
"unix charset = UTF-8", "unix charset = UTF-8",
"logging = file", "logging = file",
"log file = /var/log/samba/log.%m", "log file = /var/log/samba/log.%m",
@@ -76,6 +99,12 @@ func RenderSMB(c Config, owner string) string {
fmt.Fprintf(&b, " %s\n", l) fmt.Fprintf(&b, " %s\n", l)
} }
} }
if len(includes) > 0 {
b.WriteString("\n# Shares of other services (read-only in nasmon)\n")
for _, f := range includes {
fmt.Fprintf(&b, "include = %s\n", f)
}
}
return b.String() return b.String()
} }
@@ -102,7 +131,7 @@ func RenderExports(c Config, uid, gid string) string {
default: default:
opts = append(opts, "root_squash", "anonuid="+uid, "anongid="+gid) 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") opts = append(opts, "crossmnt", "no_subtree_check")
var parts []string var parts []string
for _, cl := range e.Clients { for _, cl := range e.Clients {
+41 -2
View File
@@ -40,6 +40,19 @@ func TestRenderSMB(t *testing.T) {
if strings.Count(out, "\n veto files = ") != 1 { if strings.Count(out, "\n veto files = ") != 1 {
t.Errorf("veto files only where requested") 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) { func TestRenderExports(t *testing.T) {
@@ -70,12 +83,13 @@ func TestValidation(t *testing.T) {
t.Errorf("%q accepted", n) 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 { if err := ValidatePath(p); err != nil {
t.Errorf("%q rejected: %v", p, err) 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 { if ValidatePath(p) == nil {
t.Errorf("%q accepted", p) t.Errorf("%q accepted", p)
} }
@@ -211,6 +225,7 @@ func newManager(f *fake) *Manager {
m := New(f.run) m := New(f.run)
m.LookPath = f.lookPath m.LookPath = f.lookPath
m.Owner, m.UID, m.GID = "ansible", "1000", "1000" m.Owner, m.UID, m.GID = "ansible", "1000", "1000"
m.IncludeDir, m.ExportsDir = "", "" // never read the host's drop-ins in tests
return m return m
} }
@@ -336,3 +351,27 @@ func TestUsers(t *testing.T) {
t.Errorf("owner account deleted") 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 return nil
} }
// ValidatePath accepts an absolute, clean path without control characters. // systemRoots are OS trees that must never be shared.
// Whether it lies under a pool is checked by the caller (needs live state). 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 { func ValidatePath(p string) error {
if p == "" { if p == "" {
return errors.New("path is required") 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") { 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)") return errors.New("path must be absolute and normalised (no .., trailing / or quotes)")
} }
if p == "/" { return ValidateNotSystem(p)
return errors.New("refusing to share the root filesystem")
}
return nil
} }
// UnderRoots reports whether p equals or lies below one of the roots. // UnderRoots reports whether p equals or lies below one of the roots.
+32
View File
@@ -0,0 +1,32 @@
package web
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"nasmon/internal/shares"
)
func TestSharesPageListsExternal(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &shareRunner{}
withShareRunner(srv, f)
dir := t.TempDir()
smbDir, nfsDir := filepath.Join(dir, "smb.conf.d"), filepath.Join(dir, "exports.d")
_ = os.MkdirAll(smbDir, 0o755)
_ = os.MkdirAll(nfsDir, 0o755)
_ = os.WriteFile(filepath.Join(smbDir, "repoiso.conf"), []byte("[iso-pve]\n path = /tank/iso/export/pve\n"), 0o644)
_ = os.WriteFile(filepath.Join(nfsDir, "repoiso.exports"), []byte("\"/tank/iso/export/pve\" 10.10.0.0/24(ro)\n"), 0o644)
srv.shm.IncludeDir, srv.shm.ExportsDir, srv.shm.Exports = smbDir, nfsDir, filepath.Join(nfsDir, shares.DefaultExports)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/shares", nil))
body := rec.Body.String()
for _, want := range []string{`id="external"`, "iso-pve", "/tank/iso/export/pve", "10.10.0.0/24(ro)", ">repoiso<"} {
if !strings.Contains(body, want) {
t.Errorf("shares page lacks %q", want)
}
}
}
+42 -5
View File
@@ -124,6 +124,8 @@ type zfsVM struct {
S sched.Status S sched.Status
Th config.Thresholds Th config.Thresholds
Datasets []datasetRow Datasets []datasetRow
// PoolDedup maps a pool to the dedup property of its root dataset.
PoolDedup map[string]string
Form createForm // last submitted values (re-rendered after an error) Form createForm // last submitted values (re-rendered after an error)
} }
@@ -134,15 +136,18 @@ type datasetRow struct {
} }
type createForm struct { type createForm struct {
Pool, Name, Owner, Compression, Quota string Pool, Name, Owner, Compression, Dedup, Quota string
} }
func (s *Server) zfsView(st sched.Status, form createForm) zfsVM { func (s *Server) zfsView(st sched.Status, form createForm) zfsVM {
vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form} vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form, PoolDedup: map[string]string{}}
if vm.Form.Owner == "" { if vm.Form.Owner == "" {
vm.Form.Owner = defaultOwner vm.Form.Owner = defaultOwner
} }
for _, d := range st.Datasets { for _, d := range st.Datasets {
if !strings.Contains(d.Name, "/") {
vm.PoolDedup[d.Name] = d.Dedup
}
row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)} row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)}
row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer
for _, o := range st.Datasets { for _, o := range st.Datasets {
@@ -170,6 +175,7 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"), Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"),
Owner: strings.TrimSpace(r.FormValue("owner")), Owner: strings.TrimSpace(r.FormValue("owner")),
Compression: r.FormValue("compression"), Compression: r.FormValue("compression"),
Dedup: r.FormValue("dedup"),
Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))), Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))),
} }
st := s.sch.Status() st := s.sch.Status()
@@ -191,20 +197,20 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateSize(f.Quota)} { for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateDedup(f.Dedup), zfsadm.ValidateSize(f.Quota)} {
if err != nil { if err != nil {
fail(err.Error()) fail(err.Error())
return return
} }
} }
err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Quota: f.Quota, Owner: f.Owner}) err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Dedup: f.Dedup, Quota: f.Quota, Owner: f.Owner})
s.sch.RefreshStorage() s.sch.RefreshStorage()
if err != nil { if err != nil {
log.Printf("zfs create %s: %v", full, err) log.Printf("zfs create %s: %v", full, err)
fail(err.Error()) fail(err.Error())
return return
} }
log.Printf("dataset %s created (owner %s, compression %q, quota %q)", full, f.Owner, f.Compression, f.Quota) log.Printf("dataset %s created (owner %s, compression %q, dedup %q, quota %q)", full, f.Owner, f.Compression, f.Dedup, f.Quota)
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther) http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther)
} }
@@ -243,6 +249,37 @@ func (s *Server) zfsDestroy(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther) http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther)
} }
// zfsDedup turns dedup on/off on a pool's root dataset (inherited by children).
func (s *Server) zfsDedup(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pool := strings.TrimSpace(r.FormValue("pool"))
value := r.FormValue("dedup")
st := s.sch.Status()
fail := func(msg string) {
s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Error: "Dedup not changed: " + msg})
}
if !hasPool(st, pool) {
fail("unknown pool " + pool)
return
}
if value != "on" && value != "off" {
fail("dedup must be on or off")
return
}
err := s.admin.SetDedup(pool, value)
s.sch.RefreshStorage()
if err != nil {
log.Printf("zfs set dedup=%s %s: %v", value, pool, err)
fail(err.Error())
return
}
log.Printf("dedup=%s set on pool %s", value, pool)
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dedup "+value+" for "+pool)+"#"+url.PathEscape(pool), http.StatusSeeOther)
}
func hasPool(st sched.Status, name string) bool { func hasPool(st sched.Status, name string) bool {
for _, p := range st.Pools { for _, p := range st.Pools {
if p.Name == name { if p.Name == name {
+8 -2
View File
@@ -9,6 +9,7 @@ import (
"log" "log"
"math" "math"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -38,7 +39,10 @@ type Server struct {
pages map[string]*template.Template pages map[string]*template.Template
mux *http.ServeMux 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 mu sync.Mutex
shareErr string // last failed Apply of the share config 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 { func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Scheduler, sh *shares.Store) *Server {
run := hostrun.Auto() run := hostrun.Auto()
s := &Server{cfg: cfg, st: st, ring: ring, sch: sch, send: alert.SendMail, 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{} s.pages = map[string]*template.Template{}
layout := template.Must(template.New("layout").Funcs(funcs).ParseFS(assets, "templates/layout.html")) layout := template.Must(template.New("layout").Funcs(funcs).ParseFS(assets, "templates/layout.html"))
for _, name := range []string{"dashboard", "disk", "zfs", "alerts", "settings", "shares"} { for _, name := range []string{"dashboard", "disk", "zfs", "alerts", "settings", "shares"} {
@@ -68,6 +73,7 @@ func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Sche
s.mux.HandleFunc("GET /zfs", s.zfs) s.mux.HandleFunc("GET /zfs", s.zfs)
s.mux.HandleFunc("POST /zfs/create", s.zfsCreate) s.mux.HandleFunc("POST /zfs/create", s.zfsCreate)
s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy) s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy)
s.mux.HandleFunc("POST /zfs/dedup", s.zfsDedup)
s.mux.HandleFunc("GET /alerts", s.alerts) s.mux.HandleFunc("GET /alerts", s.alerts)
s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction) s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction)
s.mux.HandleFunc("GET /shares", s.sharesPage) s.mux.HandleFunc("GET /shares", s.sharesPage)
+40 -19
View File
@@ -39,6 +39,7 @@ type sharesVM struct {
NForm nfsForm NForm nfsForm
ApplyErr string // last Apply failure: config saved but not live ApplyErr string // last Apply failure: config saved but not live
Squashes []string Squashes []string
External []shares.ExternalShare // shares of other services (drop-ins), read-only
} }
func (s *Server) applyErr() string { func (s *Server) applyErr() string {
@@ -53,21 +54,39 @@ func (s *Server) setApplyErr(e string) {
s.mu.Unlock() s.mu.Unlock()
} }
// mountpoints lists dataset mountpoints — the only places shares may point to. // pathSuggestions lists ZFS dataset mountpoints plus other mounted data
func mountpoints(st sched.Status) []string { // 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 var out []string
for _, d := range st.Datasets { add := func(p string) {
if strings.HasPrefix(d.Mountpoint, "/") { if strings.HasPrefix(p, "/") && !seen[p] && shares.ValidateNotSystem(p) == nil {
out = append(out, d.Mountpoint) 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) sort.Strings(out)
return 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 { func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) sharesVM {
c := s.shares.Get() 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"}} Form: form, NForm: nform, ApplyErr: s.applyErr(), Squashes: []string{"root", "all", "none"}}
if vm.Form.Name == "" && vm.Form.Orig == "" { if vm.Form.Name == "" && vm.Form.Orig == "" {
vm.Form.New, vm.Form.Guest, vm.Form.Browseable, vm.Form.HideSys = true, true, true, true vm.Form.New, vm.Form.Guest, vm.Form.Browseable, vm.Form.HideSys = true, true, true, true
@@ -78,6 +97,7 @@ func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) shares
vm.NForm.Squash = "root" vm.NForm.Squash = "root"
} }
} }
vm.External = s.shm.External()
if u, err := s.shm.ListUsers(); err == nil { if u, err := s.shm.ListUsers(); err == nil {
vm.Users = u vm.Users = u
} else { } else {
@@ -147,17 +167,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) 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. // pathOK checks that a share path is an existing, non-system directory, also
func (s *Server) pathOK(st sched.Status, p string) error { // after resolving symlinks (so /tank/link -> /etc is refused).
func (s *Server) pathOK(p string) error {
if err := shares.ValidatePath(p); err != nil { if err := shares.ValidatePath(p); err != nil {
return err return err
} }
if !shares.UnderRoots(p, mountpoints(st)) {
return errPathOutside
}
if !s.dirExists(p) { if !s.dirExists(p) {
return errPathMissing 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 return nil
} }
@@ -170,10 +195,7 @@ type strErr string
func (e strErr) Error() string { return string(e) } func (e strErr) Error() string { return string(e) }
const ( const errPathMissing = strErr("path does not exist or is not a directory")
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) { func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -184,6 +206,7 @@ func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) {
c.SMB.Enabled = r.FormValue("enabled") != "" c.SMB.Enabled = r.FormValue("enabled") != ""
c.SMB.Workgroup = strings.ToUpper(strings.TrimSpace(r.FormValue("workgroup"))) c.SMB.Workgroup = strings.ToUpper(strings.TrimSpace(r.FormValue("workgroup")))
c.SMB.ServerString = strings.TrimSpace(r.FormValue("server_string")) c.SMB.ServerString = strings.TrimSpace(r.FormValue("server_string"))
c.SMB.Legacy = r.FormValue("legacy") != ""
if err := shares.ValidateComment(c.SMB.ServerString); err != nil { if err := shares.ValidateComment(c.SMB.ServerString); err != nil {
s.sharesFail(w, r, "Not saved: server description: "+err.Error(), smbForm{}, nfsForm{}) s.sharesFail(w, r, "Not saved: server description: "+err.Error(), smbForm{}, nfsForm{})
return return
@@ -210,7 +233,6 @@ func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) {
if len(f.Path) > 1 { if len(f.Path) > 1 {
f.Path = strings.TrimRight(f.Path, "/") 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, 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} Users: f.Users, Browseable: f.Browseable, HideSysFiles: f.HideSys}
if sh.Guest { if sh.Guest {
@@ -220,7 +242,7 @@ func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{}) s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return 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{}) s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return return
} }
@@ -276,13 +298,12 @@ func (s *Server) nfsSave(w http.ResponseWriter, r *http.Request) {
if len(f.Path) > 1 { if len(f.Path) > 1 {
f.Path = strings.TrimRight(f.Path, "/") 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} 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 { if err := e.Validate(); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f) s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return 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) s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return return
} }
+40 -2
View File
@@ -59,6 +59,7 @@ func withShareRunner(srv *Server, f *shareRunner) {
srv.shm = shares.New(f.run) srv.shm = shares.New(f.run)
srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil } srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil }
srv.shm.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000" srv.shm.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000"
srv.shm.IncludeDir, srv.shm.ExportsDir = "", ""
} }
func TestSharesSMBFlow(t *testing.T) { func TestSharesSMBFlow(t *testing.T) {
@@ -68,7 +69,7 @@ func TestSharesSMBFlow(t *testing.T) {
// page lists users and mountpoints, prefills from ?path= // page lists users and mountpoints, prefills from ?path=
body := get(srv, "/shares?path=/tank/data").Body.String() 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) { if !strings.Contains(body, want) {
t.Errorf("shares page missing %q", want) t.Errorf("shares page missing %q", want)
} }
@@ -96,10 +97,14 @@ func TestSharesSMBFlow(t *testing.T) {
t.Errorf("share not listed") 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{ for _, form := range []url.Values{
{"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}}, {"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}},
{"name": {"etc"}, "path": {"/etc"}, "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": {"priv"}, "path": {"/tank/data"}},
{"name": {"global"}, "path": {"/tank/data"}, "guest": {"1"}}, {"name": {"global"}, "path": {"/tank/data"}, "guest": {"1"}},
{"name": {"gone"}, "path": {"/tank/nope/.."}, "guest": {"1"}}, {"name": {"gone"}, "path": {"/tank/nope/.."}, "guest": {"1"}},
@@ -125,6 +130,12 @@ func TestSharesSMBFlow(t *testing.T) {
if !strings.Contains(f.written(shares.DefaultSMBConf), "valid users = alice") { if !strings.Contains(f.written(shares.DefaultSMBConf), "valid users = alice") {
t.Errorf("smb.conf not regenerated after edit") 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 // a user still referenced by a share cannot be deleted
rec = postForm(srv, "/shares/users/delete", url.Values{"name": {"alice"}}) 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") { if rec.Code != 200 || !strings.Contains(rec.Body.String(), "still allowed on share private") || f.has("smbpasswd -x") {
@@ -192,11 +203,17 @@ func TestSharesNFSFlow(t *testing.T) {
{"path": {"/tank/data2"}, "clients": {""}, "squash": {"root"}}, // no clients {"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": {"10.0.0.0/33"}, "squash": {"root"}}, // bad cidr
{"path": {"/tank/data2"}, "clients": {"*"}, "squash": {"weird"}}, // bad squash {"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") { if rec := postForm(srv, "/shares/nfs/save", form); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") {
t.Errorf("form %v accepted", form) 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() body := get(srv, "/shares").Body.String()
if !strings.Contains(body, "192.168.101.0/24, pc.lan") || !strings.Contains(body, ":/tank/data") { if !strings.Contains(body, "192.168.101.0/24, pc.lan") || !strings.Contains(body, ":/tank/data") {
t.Errorf("export not listed") t.Errorf("export not listed")
@@ -210,3 +227,24 @@ func TestSharesNFSFlow(t *testing.T) {
t.Errorf("delete: %d %v", rec.Code, f.calls) 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")
}
}
+28 -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 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>Workgroup <input name="workgroup" value="{{.C.SMB.Workgroup}}"></label>
<label>Description <input name="server_string" value="{{.C.SMB.ServerString}}"></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> <div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Save SMB settings</button></span></div>
</form> </form>
@@ -47,7 +48,7 @@
<input type="hidden" name="orig" value="{{.Form.Orig}}"> <input type="hidden" name="orig" value="{{.Form.Orig}}">
<div class="form-grid"> <div class="form-grid">
<label>Share name <input name="name" required value="{{.Form.Name}}" placeholder="documents"></label> <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>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="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> <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> <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>
</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> <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> <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}} {{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> <div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Add user / set password</button></span></div>
</form> </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>. <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>
<section class="card" id="nfs"> <section class="card" id="nfs">
@@ -118,7 +121,7 @@
<form method="post" action="/shares/nfs/save" class="ds-form"> <form method="post" action="/shares/nfs/save" class="ds-form">
<input type="hidden" name="orig" value="{{.NForm.Orig}}"> <input type="hidden" name="orig" value="{{.NForm.Orig}}">
<div class="form-grid"> <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>Clients <input name="clients" required value="{{.NForm.Clients}}" placeholder="192.168.101.0/24, pc.lan, *"></label>
<label>Squash <label>Squash
<select name="squash"> <select name="squash">
@@ -130,12 +133,33 @@
<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="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> <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> </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> <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}} {{if not .NForm.New}}<a href="/shares" class="small">cancel</a>{{end}}
</form> </form>
</details> </details>
<p class="muted small">Linux: <span class="mono">sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas</span>; list exports with <span class="mono">showmount -e {{.Host}}</span>.</p> <p class="muted small">Linux: <span class="mono">sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas</span>; list exports with <span class="mono">showmount -e {{.Host}}</span>.</p>
</section> </section>
{{if .External}}
<section class="card" id="external">
<h2>Shares of other services <span class="muted small">read-only</span></h2>
<p class="muted small">Defined by other software on this host in <span class="mono">/etc/samba/smb.conf.d/*.conf</span>
(included into smb.conf) and <span class="mono">/etc/exports.d/*.exports</span>. Manage them in that software;
nasmon keeps smbd / nfs-server running while any of them exist.</p>
<div class="tablewrap"><table>
<thead><tr><th>Type</th><th>Source</th><th>Name</th><th>Path</th><th>Clients</th></tr></thead>
<tbody>
{{range .External}}<tr>
<td><span class="badge">{{if eq .Kind "smb"}}SMB{{else}}NFS{{end}}</span></td>
<td class="mono">{{.Source}}</td>
<td class="mono">{{if .Name}}\\{{$.Data.Host}}\{{.Name}}{{else}}—{{end}}</td>
<td class="mono small">{{.Path}}</td>
<td class="mono small">{{.Clients}}</td>
</tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{end}} {{end}}
{{end}} {{end}}
+23
View File
@@ -13,6 +13,7 @@
<div><span class="muted">fragmentation</span> {{.FragPct}}%</div> <div><span class="muted">fragmentation</span> {{.FragPct}}%</div>
<div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div> <div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div>
<div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div> <div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div>
<div><span class="muted">dedup</span> {{with index $.Data.PoolDedup .Name}}{{.}}{{else}}—{{end}}{{if .DedupRatio}} (ratio {{printf "%.2f" .DedupRatio}}x){{end}}</div>
</div> </div>
<div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div> <div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div>
{{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}} {{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}}
@@ -31,6 +32,21 @@
{{end}} {{end}}
</tbody> </tbody>
</table></div> </table></div>
{{$dedup := index $.Data.PoolDedup .Name}}
<details class="dedup dedup-{{.Name}}">
<summary>Deduplication</summary>
<form method="post" action="/zfs/dedup" class="inline">
<input type="hidden" name="pool" value="{{.Name}}">
<label>Dedup for {{.Name}}
<select name="dedup">
<option value="off" {{if or (eq $dedup "off") (eq $dedup "")}}selected{{end}}>off</option>
<option value="on" {{if and (ne $dedup "off") (ne $dedup "")}}selected{{end}}>on</option>
</select>
</label>
<button type="submit">Apply</button>
</form>
<p class="small muted">Sets dedup on the pool root dataset; child datasets inherit it unless they override it. Only newly written data is deduplicated, and turning it off keeps the existing dedup table. The dedup table needs a lot of RAM (up to ~5 GB per 1 TB of unique data) &mdash; enable it only for data with many duplicate blocks.</p>
</details>
<details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}> <details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}>
<summary>New dataset in {{.Name}}</summary> <summary>New dataset in {{.Name}}</summary>
<form method="post" action="/zfs/create" class="ds-form"> <form method="post" action="/zfs/create" class="ds-form">
@@ -46,6 +62,13 @@
<option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option> <option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option>
</select> </select>
</label> </label>
<label>Dedup
<select name="dedup">
<option value="" {{if eq $form.Dedup ""}}selected{{end}}>inherit</option>
<option value="on" {{if eq $form.Dedup "on"}}selected{{end}}>on</option>
<option value="off" {{if eq $form.Dedup "off"}}selected{{end}}>off</option>
</select>
</label>
<label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label> <label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label>
</div> </div>
<p class="small muted">Creates {{.Name}}/&lt;name&gt; (parents are created too), mounts it at /{{.Name}}/&lt;name&gt; and chowns the mountpoint to the owner.</p> <p class="small muted">Creates {{.Name}}/&lt;name&gt; (parents are created too), mounts it at /{{.Name}}/&lt;name&gt; and chowns the mountpoint to the owner.</p>
+56 -6
View File
@@ -68,12 +68,12 @@ func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
}, },
Pools: []collect.Pool{ Pools: []collect.Pool{
{Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old, {Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old,
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors", Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors", DedupRatio: 1.42,
Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}}, Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}},
{Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"}, {Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"},
}, },
Datasets: []collect.Dataset{ Datasets: []collect.Dataset{
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank"}, {Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank", Dedup: "on"},
{Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"}, {Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"},
{Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"}, {Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"},
{Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"}, {Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"},
@@ -89,7 +89,18 @@ func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
t.Fatal(err) t.Fatal(err)
} }
srv := New(cfg, st, ring, sc, shs) 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 return srv, sc, st
} }
@@ -234,7 +245,7 @@ func TestZfsPageControls(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil)) srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil))
body := rec.Body.String() body := rec.Body.String()
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm"} { for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm", `action="/zfs/dedup"`, "Dedup for tmp_pool", "ratio 1.42x"} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("zfs page missing %q", want) t.Errorf("zfs page missing %q", want)
} }
@@ -263,6 +274,7 @@ func TestZfsCreate(t *testing.T) {
{"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota {"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota
{"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner {"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner
{"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}}, {"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}},
{"pool": {"tank"}, "name": {"x"}, "dedup": {"verify"}},
} }
for _, form := range bad { for _, form := range bad {
f.calls = nil f.calls = nil
@@ -278,11 +290,11 @@ func TestZfsCreate(t *testing.T) {
} }
} }
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "quota": {"100g"}}) rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "dedup": {"on"}, "quota": {"100g"}})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") { if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") {
t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
} }
want := "zfs create -p -o compression=lz4 -o quota=100G tank/media/video" want := "zfs create -p -o compression=lz4 -o dedup=on -o quota=100G tank/media/video"
if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" { if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" {
t.Errorf("calls = %v", f.calls) t.Errorf("calls = %v", f.calls)
} }
@@ -296,6 +308,44 @@ func TestZfsCreate(t *testing.T) {
} }
} }
func TestZfsDedup(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &fakeRunner{}
srv.admin = zfsadm.New(f.run)
bad := []url.Values{
{"pool": {"nope"}, "dedup": {"on"}}, // unknown pool
{"pool": {"tank/data"}, "dedup": {"on"}}, // not a pool
{"pool": {"tank"}, "dedup": {"verify"}}, // unsupported value
{"pool": {"tank"}}, // missing value
}
for _, form := range bad {
f.calls = nil
rec := postForm(srv, "/zfs/dedup", form)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Dedup not changed") {
t.Errorf("%v: status %d, no error shown", form, rec.Code)
}
if len(f.calls) != 0 {
t.Errorf("%v: commands ran: %v", form, f.calls)
}
}
rec := postForm(srv, "/zfs/dedup", url.Values{"pool": {"tmp_pool"}, "dedup": {"on"}})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "#tmp_pool") {
t.Fatalf("status %d location %q", rec.Code, rec.Header().Get("Location"))
}
if len(f.calls) != 1 || f.calls[0] != "zfs set dedup=on tmp_pool" {
t.Errorf("calls = %v", f.calls)
}
f = &fakeRunner{fail: "zfs set"}
srv.admin = zfsadm.New(f.run)
rec = postForm(srv, "/zfs/dedup", url.Values{"pool": {"tank"}, "dedup": {"off"}})
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") {
t.Errorf("failure not reported: %d", rec.Code)
}
}
func TestZfsDestroy(t *testing.T) { func TestZfsDestroy(t *testing.T) {
srv, _, _ := newTestServer(t) srv, _, _ := newTestServer(t)
f := &fakeRunner{} f := &fakeRunner{}
+35 -1
View File
@@ -1,5 +1,5 @@
// Package zfsadm performs the few mutating ZFS operations the UI offers // Package zfsadm performs the few mutating ZFS operations the UI offers
// (create / destroy datasets). Commands go through a Runner so they can be // (create / destroy datasets, toggle pool dedup). Commands go through a Runner so they can be
// executed on the host via systemd-run: nasmon runs in a sandboxed mount // executed on the host via systemd-run: nasmon runs in a sandboxed mount
// namespace (ProtectSystem=strict), where a plain "zfs create" would mount the // namespace (ProtectSystem=strict), where a plain "zfs create" would mount the
// new dataset only inside the service and leave the host without it. // new dataset only inside the service and leave the host without it.
@@ -59,6 +59,7 @@ func (a *Admin) ctx() (context.Context, context.CancelFunc) {
// CreateOpts are the user-visible knobs of "zfs create". // CreateOpts are the user-visible knobs of "zfs create".
type CreateOpts struct { type CreateOpts struct {
Compression string // "", lz4, zstd, off ("" = inherit) Compression string // "", lz4, zstd, off ("" = inherit)
Dedup string // "", on, off ("" = inherit)
Quota string // "", or a size like 100G / 1.5T Quota string // "", or a size like 100G / 1.5T
Owner string // uid:gid applied to the mountpoint; "" = leave root Owner string // uid:gid applied to the mountpoint; "" = leave root
} }
@@ -71,6 +72,9 @@ func (a *Admin) Create(full string, o CreateOpts) error {
if err := ValidateCompression(o.Compression); err != nil { if err := ValidateCompression(o.Compression); err != nil {
return err return err
} }
if err := ValidateDedup(o.Dedup); err != nil {
return err
}
if err := ValidateSize(o.Quota); err != nil { if err := ValidateSize(o.Quota); err != nil {
return err return err
} }
@@ -81,6 +85,9 @@ func (a *Admin) Create(full string, o CreateOpts) error {
if o.Compression != "" { if o.Compression != "" {
args = append(args, "-o", "compression="+o.Compression) args = append(args, "-o", "compression="+o.Compression)
} }
if o.Dedup != "" {
args = append(args, "-o", "dedup="+o.Dedup)
}
if o.Quota != "" { if o.Quota != "" {
args = append(args, "-o", "quota="+o.Quota) args = append(args, "-o", "quota="+o.Quota)
} }
@@ -106,6 +113,25 @@ func (a *Admin) Create(full string, o CreateOpts) error {
return nil return nil
} }
// SetDedup sets the dedup property (on/off) on the root dataset of pool, so it
// applies to every dataset that does not override it. Only newly written
// blocks are affected either way.
func (a *Admin) SetDedup(pool, value string) error {
if err := ValidateFullName(pool); err != nil {
return err
}
if strings.Contains(pool, "/") {
return errors.New("dedup is set on the pool root dataset only")
}
if value != "on" && value != "off" {
return errors.New("dedup must be on or off")
}
ctx, cancel := a.ctx()
defer cancel()
_, err := a.Run(ctx, "zfs", "set", "dedup="+value, pool)
return err
}
// EmptyRefer is the upper bound of "referenced" bytes for a dataset we still // EmptyRefer is the upper bound of "referenced" bytes for a dataset we still
// consider empty (a fresh dataset refers ~96-128 KiB of metadata). // consider empty (a fresh dataset refers ~96-128 KiB of metadata).
const EmptyRefer = 1 << 20 const EmptyRefer = 1 << 20
@@ -206,6 +232,14 @@ func ValidateCompression(s string) error {
return errors.New("compression must be inherit, lz4, zstd or off") return errors.New("compression must be inherit, lz4, zstd or off")
} }
func ValidateDedup(s string) error {
switch s {
case "", "on", "off":
return nil
}
return errors.New("dedup must be inherit, on or off")
}
func ValidateOwner(s string) error { func ValidateOwner(s string) error {
if s == "" || reOwner.MatchString(s) { if s == "" || reOwner.MatchString(s) {
return nil return nil
+35
View File
@@ -31,6 +31,9 @@ func TestValidators(t *testing.T) {
if ValidateCompression("gzip") == nil || ValidateCompression("lz4") != nil || ValidateCompression("") != nil { if ValidateCompression("gzip") == nil || ValidateCompression("lz4") != nil || ValidateCompression("") != nil {
t.Error("compression validation") t.Error("compression validation")
} }
if ValidateDedup("verify") == nil || ValidateDedup("on") != nil || ValidateDedup("") != nil {
t.Error("dedup validation")
}
if ValidateOwner("ansible") == nil || ValidateOwner("1000:1000") != nil || ValidateOwner("") != nil { if ValidateOwner("ansible") == nil || ValidateOwner("1000:1000") != nil || ValidateOwner("") != nil {
t.Error("owner validation") t.Error("owner validation")
} }
@@ -74,6 +77,16 @@ func TestCreate(t *testing.T) {
t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n")) t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
} }
// dedup goes between compression and quota
f = &fake{}
a = New(f.run)
if err := a.Create("tank/d", CreateOpts{Compression: "zstd", Dedup: "on", Quota: "1T"}); err != nil {
t.Fatal(err)
}
if len(f.calls) != 1 || f.calls[0] != "zfs create -p -o compression=zstd -o dedup=on -o quota=1T tank/d" {
t.Errorf("calls = %v", f.calls)
}
// no owner, inherit everything // no owner, inherit everything
f = &fake{} f = &fake{}
a = New(f.run) a = New(f.run)
@@ -115,6 +128,28 @@ func TestCreate(t *testing.T) {
} }
} }
func TestSetDedup(t *testing.T) {
f := &fake{}
a := New(f.run)
for _, c := range [][2]string{{"tank/x", "on"}, {"tank", "verify"}, {"tank", ""}, {"-tank", "on"}, {"", "off"}} {
if err := a.SetDedup(c[0], c[1]); err == nil {
t.Errorf("%q=%q accepted", c[0], c[1])
}
}
if len(f.calls) != 0 {
t.Errorf("rejected input ran commands: %v", f.calls)
}
if err := a.SetDedup("tank", "on"); err != nil {
t.Fatal(err)
}
if err := a.SetDedup("tank", "off"); err != nil {
t.Fatal(err)
}
if strings.Join(f.calls, "|") != "zfs set dedup=on tank|zfs set dedup=off tank" {
t.Errorf("calls = %v", f.calls)
}
}
func TestDestroyEmpty(t *testing.T) { func TestDestroyEmpty(t *testing.T) {
ok := map[string]string{"zfs list -Hp -r -t filesystem": "tank/x\t98304", "zfs list -H -t snapshot": ""} ok := map[string]string{"zfs list -Hp -r -t filesystem": "tank/x\t98304", "zfs list -H -t snapshot": ""}
f := &fake{reply: ok} f := &fake{reply: ok}
+25 -3
View File
@@ -14,8 +14,11 @@ periodic SMART/ZFS checks with e-mail alerts.
The ZFS page (http://<host>:8090/zfs) can create datasets in a pool — name (nested The ZFS page (http://<host>:8090/zfs) can create datasets in a pool — name (nested
names like media/video are fine), owner uid:gid for the mountpoint, compression, names like media/video are fine), owner uid:gid for the mountpoint, compression,
optional quota — and destroy datasets that are empty (no data, no children, no dedup, optional quota — and destroy datasets that are empty (no data, no children, no
snapshots; the check is repeated against the live system before "zfs destroy"). snapshots; the check is repeated against the live system before "zfs destroy").
Each pool card also shows the dedup ratio and can turn dedup on/off on the pool
root dataset (`zfs set dedup=on|off <pool>`, inherited by child datasets; only new
writes are affected, and the dedup table needs a lot of RAM).
nasmon runs sandboxed (ProtectSystem=strict → own mount namespace), so these nasmon runs sandboxed (ProtectSystem=strict → own mount namespace), so these
commands are executed on the host through transient units: commands are executed on the host through transient units:
@@ -29,13 +32,32 @@ nasmon has no authentication: keep port 8090 on the LAN only.
http://<host>:8090/shares manages Samba shares (guest or per-user, files always 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 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, 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/samba/smb.conf (whole file, header "Managed by nasmon") and
/etc/exports.d/nasmon.exports, validates with testparm, then reloads smbd / /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 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 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. problem changes or clears); the × on a resolved alert removes it from the log.
Resolved alerts older than 7 days are hidden by default, older than 90 days Resolved alerts older than 7 days are hidden by default, older than 90 days
deleted. deleted.
## Shares of other services
Other software on the host can publish shares without fighting over the
config files nasmon owns:
* SMB: drop a fragment into /etc/samba/smb.conf.d/<name>.conf. nasmon appends
"include = <file>" for every *.conf there to the smb.conf it generates.
* NFS: write /etc/exports.d/<name>.exports.
nasmon lists these shares read-only on /shares and keeps smbd / nfs-server
enabled while any of them exist, even with no nasmon shares (previously it
disabled the daemons and took such shares down). When SMB is switched off in
nasmon, only nasmon's own shares are removed.