Initial import: nasmon health monitor, split out of home_nas

Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:24:34 +03:00
co-authored by Claude Sonnet 5
commit 09159a5f31
61 changed files with 7882 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
package shares
import (
"context"
"errors"
"fmt"
"log"
"os/exec"
"os/user"
"strings"
"time"
"nasmon/internal/hostrun"
)
const (
DefaultSMBConf = "/etc/samba/smb.conf"
DefaultExports = "/etc/exports.d/nasmon.exports"
// DefaultOwnerUID is the account SMB sessions and squashed NFS clients act
// as: the user that owns the data and runs the containers on this NAS.
DefaultOwnerUID = "1000"
)
// Manager writes the generated config files on the host and reloads the daemons.
type Manager struct {
Run hostrun.Runner
LookPath func(string) (string, error)
Owner string // Unix user name for force user
UID, GID string
SMBConf string
Exports string
Timeout time.Duration
}
func New(run hostrun.Runner) *Manager {
m := &Manager{Run: run, LookPath: exec.LookPath, UID: DefaultOwnerUID, GID: DefaultOwnerUID,
SMBConf: DefaultSMBConf, Exports: DefaultExports, Timeout: 90 * time.Second}
m.Owner = m.UID
if u, err := user.LookupId(m.UID); err == nil {
m.Owner, m.GID = u.Username, u.Gid
}
return m
}
func (m *Manager) ctx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), m.Timeout)
}
func (m *Manager) installed(bin string) bool {
_, err := m.LookPath(bin)
return err == nil
}
func (m *Manager) SMBInstalled() bool { return m.installed("smbd") }
func (m *Manager) NFSInstalled() bool { return m.installed("exportfs") }
// Apply regenerates both config files and reloads/enables/disables the
// services. SMB and NFS are applied independently; errors are joined.
func (m *Manager) Apply(c Config) error {
var errs []error
if m.SMBInstalled() {
if err := m.applySMB(c); err != nil {
errs = append(errs, fmt.Errorf("SMB: %w", err))
}
}
if m.NFSInstalled() {
if err := m.applyNFS(c); err != nil {
errs = append(errs, fmt.Errorf("NFS: %w", err))
}
}
return errors.Join(errs...)
}
func (m *Manager) applySMB(c Config) error {
ctx, cancel := m.ctx()
defer cancel()
if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(c, m.Owner)); err != nil {
return fmt.Errorf("write %s: %w", m.SMBConf, err)
}
if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil {
_ = hostrun.Restore(ctx, m.Run, m.SMBConf)
return fmt.Errorf("testparm rejected the config, previous file restored: %s", firstLines(out, 4))
}
active := c.SMB.Enabled && len(c.SMB.Shares) > 0
if !active {
_, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd")
if err != nil {
return fmt.Errorf("stop smbd: %w", err)
}
log.Printf("shares: SMB disabled (%d shares, enabled=%v)", len(c.SMB.Shares), c.SMB.Enabled)
return nil
}
if _, err := m.Run(ctx, "", "systemctl", "enable", "--now", "smbd", "nmbd"); err != nil {
return fmt.Errorf("start smbd: %w", err)
}
if out, err := m.Run(ctx, "", "smbcontrol", "all", "reload-config"); err != nil {
return fmt.Errorf("reload smbd: %s", firstLines(out, 2))
}
log.Printf("shares: SMB config applied, %d shares", len(c.SMB.Shares))
return nil
}
func (m *Manager) applyNFS(c Config) error {
ctx, cancel := m.ctx()
defer cancel()
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(c, m.UID, m.GID)); err != nil {
return fmt.Errorf("write %s: %w", m.Exports, err)
}
active := c.NFS.Enabled && len(c.NFS.Exports) > 0
if !active {
if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil {
return fmt.Errorf("stop nfs-server: %w", err)
}
log.Printf("shares: NFS disabled (%d exports, enabled=%v)", len(c.NFS.Exports), c.NFS.Enabled)
return nil
}
if _, err := m.Run(ctx, "", "systemctl", "enable", "--now", "nfs-server"); err != nil {
return fmt.Errorf("start nfs-server: %w", err)
}
if out, err := m.Run(ctx, "", "exportfs", "-ra"); err != nil {
_ = hostrun.Restore(ctx, m.Run, m.Exports)
_, _ = m.Run(ctx, "", "exportfs", "-ra")
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))
return nil
}
func firstLines(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, " | ")
}
// ---- SMB users
// ListUsers returns the Samba accounts (pdbedit -L: "name:uid:full name").
func (m *Manager) ListUsers() ([]string, error) {
if !m.SMBInstalled() {
return nil, nil
}
ctx, cancel := m.ctx()
defer cancel()
out, err := m.Run(ctx, "", "pdbedit", "-L")
if err != nil {
return nil, err
}
var users []string
for _, l := range strings.Split(out, "\n") {
name, _, ok := strings.Cut(strings.TrimSpace(l), ":")
if ok && name != "" {
users = append(users, name)
}
}
return users, nil
}
// SetPassword creates the Samba account (and a login-less Unix account if
// needed) or changes the password of an existing one.
func (m *Manager) SetPassword(name, pass string) error {
if err := ValidateUserName(name); err != nil {
return err
}
if len(pass) < 4 || strings.ContainsAny(pass, "\n\r") {
return errors.New("password must be at least 4 characters")
}
ctx, cancel := m.ctx()
defer cancel()
if _, err := m.Run(ctx, "", "getent", "passwd", name); err != nil {
// Unix account is required by Samba; no shell, no home, data owner's group
if out, err := m.Run(ctx, "", "useradd", "-M", "-N", "-g", m.GID, "-s", "/usr/sbin/nologin", "-c", "nasmon SMB user", name); err != nil {
return fmt.Errorf("create system user: %s", firstLines(out, 2))
}
log.Printf("shares: system user %s created for SMB", name)
}
if out, err := m.Run(ctx, pass+"\n"+pass+"\n", "smbpasswd", "-s", "-a", name); err != nil {
return fmt.Errorf("smbpasswd: %s", firstLines(out, 2))
}
log.Printf("shares: SMB password set for %s", name)
return nil
}
// DeleteUser removes the Samba account; the Unix account is removed too when
// it is one nasmon created (nologin shell).
func (m *Manager) DeleteUser(name string) error {
if err := ValidateUserName(name); err != nil {
return err
}
ctx, cancel := m.ctx()
defer cancel()
if out, err := m.Run(ctx, "", "smbpasswd", "-x", name); err != nil {
return fmt.Errorf("smbpasswd -x: %s", firstLines(out, 2))
}
if ent, err := m.Run(ctx, "", "getent", "passwd", name); err == nil && strings.HasSuffix(strings.TrimSpace(ent), "/usr/sbin/nologin") && name != m.Owner {
if out, err := m.Run(ctx, "", "userdel", name); err != nil {
return fmt.Errorf("SMB account removed, but userdel failed: %s", firstLines(out, 2))
}
}
log.Printf("shares: SMB user %s deleted", name)
return nil
}
+179
View File
@@ -0,0 +1,179 @@
// Package shares manages SMB (Samba) and NFS exports: the definitions live in
// /etc/nasmon/shares.json, from which nasmon generates /etc/samba/smb.conf and
// /etc/exports.d/nasmon.exports and reloads the daemons.
package shares
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
)
type SMBShare struct {
Name string `json:"name"`
Path string `json:"path"`
Comment string `json:"comment"`
ReadOnly bool `json:"read_only"`
Guest bool `json:"guest"` // anyone on the LAN, no password
Users []string `json:"users"` // SMB users allowed when Guest is false
Browseable bool `json:"browseable"`
HideSysFiles bool `json:"hide_sys_files"` // veto ._*, .DS_Store, Thumbs.db, desktop.ini
}
type SMBConfig struct {
Enabled bool `json:"enabled"`
Workgroup string `json:"workgroup"`
ServerString string `json:"server_string"`
Shares []SMBShare `json:"shares"`
}
type NFSExport struct {
Path string `json:"path"`
Clients []string `json:"clients"` // host, IP, CIDR or *
ReadOnly bool `json:"read_only"`
Squash string `json:"squash"` // root | all | none
Async bool `json:"async"`
}
type NFSConfig struct {
Enabled bool `json:"enabled"`
Exports []NFSExport `json:"exports"`
}
type Config struct {
SMB SMBConfig `json:"smb"`
NFS NFSConfig `json:"nfs"`
}
func Defaults() Config {
return Config{
SMB: SMBConfig{Enabled: true, Workgroup: "WORKGROUP", ServerString: "Home NAS"},
NFS: NFSConfig{Enabled: true},
}
}
// FindSMB returns the index of the share with that name, or -1.
func (c *Config) FindSMB(name string) int {
for i, s := range c.SMB.Shares {
if s.Name == name {
return i
}
}
return -1
}
// FindNFS returns the index of the export for that path, or -1.
func (c *Config) FindNFS(path string) int {
for i, e := range c.NFS.Exports {
if e.Path == path {
return i
}
}
return -1
}
func (c *Config) sortShares() {
sort.Slice(c.SMB.Shares, func(i, j int) bool { return c.SMB.Shares[i].Name < c.SMB.Shares[j].Name })
sort.Slice(c.NFS.Exports, func(i, j int) bool { return c.NFS.Exports[i].Path < c.NFS.Exports[j].Path })
}
// Validate checks the whole configuration (pure; no filesystem access).
func (c *Config) Validate() error {
if err := ValidateWorkgroup(c.SMB.Workgroup); err != nil {
return err
}
seen := map[string]bool{}
for _, s := range c.SMB.Shares {
if err := s.Validate(); err != nil {
return err
}
if seen[s.Name] {
return fmt.Errorf("duplicate share name %s", s.Name)
}
seen[s.Name] = true
}
paths := map[string]bool{}
for _, e := range c.NFS.Exports {
if err := e.Validate(); err != nil {
return err
}
if paths[e.Path] {
return fmt.Errorf("duplicate NFS export %s", e.Path)
}
paths[e.Path] = true
}
return nil
}
// Store is a thread-safe holder for the share definitions backed by a file.
type Store struct {
path string
mu sync.RWMutex
cur Config
}
func Load(path string) (*Store, error) {
st := &Store{path: path, cur: Defaults()}
b, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
return st, nil
case err != nil:
return nil, err
}
if err := json.Unmarshal(b, &st.cur); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
st.cur.sortShares()
return st, nil
}
func (st *Store) Get() Config {
st.mu.RLock()
defer st.mu.RUnlock()
return st.cur.clone()
}
func (c Config) clone() Config {
out := c
out.SMB.Shares = make([]SMBShare, len(c.SMB.Shares))
for i, s := range c.SMB.Shares {
s.Users = append([]string(nil), s.Users...)
out.SMB.Shares[i] = s
}
out.NFS.Exports = make([]NFSExport, len(c.NFS.Exports))
for i, e := range c.NFS.Exports {
e.Clients = append([]string(nil), e.Clients...)
out.NFS.Exports[i] = e
}
return out
}
func (st *Store) Save(c Config) error {
if err := c.Validate(); err != nil {
return err
}
c.sortShares()
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
st.mu.Lock()
defer st.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(st.path), 0o755); err != nil {
return err
}
tmp := st.path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {
return err
}
if err := os.Rename(tmp, st.path); err != nil {
return err
}
st.cur = c.clone()
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package shares
import (
"fmt"
"strings"
)
// Header marks generated files so install scripts and humans can tell them apart.
const Header = "# Managed by nasmon — edits are overwritten. Change shares at http://<nas>:8090/shares"
func yesno(b bool) string {
if b {
return "yes"
}
return "no"
}
// 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)
g := []string{
"workgroup = " + c.SMB.Workgroup,
"server string = " + c.SMB.ServerString,
"server role = standalone server",
"security = user",
"map to guest = Bad User",
"guest account = nobody",
"server min protocol = SMB2",
"unix charset = UTF-8",
"logging = file",
"log file = /var/log/samba/log.%m",
"max log size = 1000",
"log level = 1",
"load printers = no",
"printing = bsd",
"printcap name = /dev/null",
"disable spoolss = yes",
"ea support = yes",
"vfs objects = fruit streams_xattr", // macOS clients: resource forks / Finder metadata as xattrs
"fruit:metadata = stream",
"fruit:model = MacSamba",
"dos filemode = yes",
}
for _, l := range g {
fmt.Fprintf(&b, " %s\n", l)
}
for _, s := range c.SMB.Shares {
fmt.Fprintf(&b, "\n[%s]\n", s.Name)
lines := []string{
"path = " + s.Path,
}
if s.Comment != "" {
lines = append(lines, "comment = "+s.Comment)
}
lines = append(lines,
"browseable = "+yesno(s.Browseable),
"read only = "+yesno(s.ReadOnly),
"guest ok = "+yesno(s.Guest),
)
if !s.Guest {
lines = append(lines, "valid users = "+strings.Join(s.Users, " "))
}
lines = append(lines,
"force user = "+owner,
"force group = "+owner,
"create mask = 0664",
"directory mask = 0775",
)
if s.HideSysFiles {
lines = append(lines, "veto files = /._*/.DS_Store/Thumbs.db/desktop.ini/", "delete veto files = yes")
}
for _, l := range lines {
fmt.Fprintf(&b, " %s\n", l)
}
}
return b.String()
}
// RenderExports produces the exports(5) file. uid/gid are the ids squashed
// clients are mapped to.
func RenderExports(c Config, uid, gid string) string {
var b strings.Builder
fmt.Fprintf(&b, "%s\n", Header)
for _, e := range c.NFS.Exports {
opts := []string{"rw"}
if e.ReadOnly {
opts = []string{"ro"}
}
if e.Async {
opts = append(opts, "async")
} else {
opts = append(opts, "sync")
}
switch e.Squash {
case "all":
opts = append(opts, "all_squash", "anonuid="+uid, "anongid="+gid)
case "none":
opts = append(opts, "no_root_squash")
default:
opts = append(opts, "root_squash", "anonuid="+uid, "anongid="+gid)
}
// crossmnt: child ZFS datasets are separate mounts and must be reachable
opts = append(opts, "crossmnt", "no_subtree_check")
var parts []string
for _, cl := range e.Clients {
parts = append(parts, cl+"("+strings.Join(opts, ",")+")")
}
fmt.Fprintf(&b, "%q %s\n", e.Path, strings.Join(parts, " "))
}
return b.String()
}
+338
View File
@@ -0,0 +1,338 @@
package shares
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
func sample() Config {
c := Defaults()
c.SMB.Shares = []SMBShare{
{Name: "documents", Path: "/nas_pool_1_8_gb/documents", Comment: "Docs", Guest: true, Browseable: true, HideSysFiles: true},
{Name: "private", Path: "/nas_pool_4_gb/backups", ReadOnly: true, Users: []string{"alice", "bob"}, Browseable: false},
}
c.NFS.Exports = []NFSExport{
{Path: "/nas_pool_1_8_gb/documents", Clients: []string{"192.168.101.0/24", "pc.lan"}, Squash: "root"},
{Path: "/tmp_pool/downloads", Clients: []string{"*"}, ReadOnly: true, Squash: "all", Async: true},
}
return c
}
func TestRenderSMB(t *testing.T) {
out := RenderSMB(sample(), "ansible")
for _, want := range []string{
"[global]", "workgroup = WORKGROUP", "map to guest = Bad User", "server min protocol = SMB2",
"[documents]", "path = /nas_pool_1_8_gb/documents", "comment = Docs", "guest ok = yes", "read only = no", "browseable = yes",
"veto files = /._*/.DS_Store/Thumbs.db/desktop.ini/",
"[private]", "guest ok = no", "valid users = alice bob", "read only = yes", "browseable = no",
"force user = ansible", "force group = ansible", "create mask = 0664",
} {
if !strings.Contains(out, want) {
t.Errorf("smb.conf missing %q\n%s", want, out)
}
}
if strings.Count(out, "valid users") != 1 {
t.Errorf("guest share must not list valid users")
}
if strings.Count(out, "\n veto files = ") != 1 {
t.Errorf("veto files only where requested")
}
}
func TestRenderExports(t *testing.T) {
out := RenderExports(sample(), "1000", "1000")
want := []string{
`"/nas_pool_1_8_gb/documents" 192.168.101.0/24(rw,sync,root_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check) pc.lan(rw,sync,root_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check)`,
`"/tmp_pool/downloads" *(ro,async,all_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check)`,
}
for _, w := range want {
if !strings.Contains(out, w) {
t.Errorf("exports missing %q\n%s", w, out)
}
}
none := Config{NFS: NFSConfig{Exports: []NFSExport{{Path: "/x", Clients: []string{"h"}, Squash: "none"}}}}
if !strings.Contains(RenderExports(none, "1", "1"), "no_root_squash") || strings.Contains(RenderExports(none, "1", "1"), "anonuid") {
t.Errorf("squash=none rendering wrong")
}
}
func TestValidation(t *testing.T) {
for _, n := range []string{"docs", "My.Share-1", "a"} {
if err := ValidateShareName(n); err != nil {
t.Errorf("%q rejected: %v", n, err)
}
}
for _, n := range []string{"", "global", "Homes", "print$", "-x", "a b", "a/b", strings.Repeat("x", 81), "a[b]"} {
if ValidateShareName(n) == nil {
t.Errorf("%q accepted", n)
}
}
for _, p := range []string{"/nas/x", "/tmp_pool"} {
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"} {
if ValidatePath(p) == nil {
t.Errorf("%q accepted", p)
}
}
if !UnderRoots("/nas_pool/docs/sub", []string{"/tmp_pool", "/nas_pool"}) || UnderRoots("/nas_pool2", []string{"/nas_pool"}) || UnderRoots("/etc", []string{"/"}) {
t.Errorf("UnderRoots")
}
for _, c := range []string{"*", "192.168.1.5", "192.168.1.0/24", "pc.lan", "*.lan", "fe80::1"} {
if err := ValidateClient(c); err != nil {
t.Errorf("%q rejected: %v", c, err)
}
}
for _, c := range []string{"", "192.168.1.0/33", "a b", "(rw)", "host,other"} {
if ValidateClient(c) == nil {
t.Errorf("%q accepted", c)
}
}
for _, u := range []string{"alice", "bob_2", "x-y"} {
if err := ValidateUserName(u); err != nil {
t.Errorf("%q rejected: %v", u, err)
}
}
for _, u := range []string{"", "Alice", "root", "nobody", "1abc", "a b"} {
if ValidateUserName(u) == nil {
t.Errorf("%q accepted", u)
}
}
c := sample()
if err := c.Validate(); err != nil {
t.Fatalf("sample invalid: %v", err)
}
c.SMB.Shares = append(c.SMB.Shares, SMBShare{Name: "documents", Path: "/x"})
if c.Validate() == nil {
t.Errorf("duplicate share accepted")
}
c = sample()
c.SMB.Shares[1].Users = nil
if c.Validate() == nil {
t.Errorf("non-guest share without users accepted")
}
c = sample()
c.NFS.Exports[0].Squash = "maybe"
if c.Validate() == nil {
t.Errorf("bad squash accepted")
}
if got := SplitList(" a, b;c\n d "); strings.Join(got, "|") != "a|b|c|d" {
t.Errorf("SplitList = %v", got)
}
}
func TestStoreRoundTrip(t *testing.T) {
p := filepath.Join(t.TempDir(), "shares.json")
st, err := Load(p)
if err != nil {
t.Fatal(err)
}
if err := st.Save(sample()); err != nil {
t.Fatal(err)
}
st2, err := Load(p)
if err != nil {
t.Fatal(err)
}
c := st2.Get()
if len(c.SMB.Shares) != 2 || len(c.NFS.Exports) != 2 || c.FindSMB("private") != 1 || c.FindNFS("/tmp_pool/downloads") != 1 {
t.Errorf("round trip: %+v", c)
}
// Get returns a copy
c.SMB.Shares[0].Name = "changed"
if st2.Get().SMB.Shares[0].Name == "changed" {
t.Errorf("Get leaked internal state")
}
}
// fake records host commands; stdin is captured for writes.
type fake struct {
calls []string
stdin map[string]string
fail map[string]string
reply map[string]string
lookup map[string]bool
}
func newFake() *fake {
return &fake{stdin: map[string]string{}, fail: map[string]string{}, reply: map[string]string{}, lookup: map[string]bool{"smbd": true, "exportfs": true}}
}
func (f *fake) run(_ context.Context, stdin, name string, args ...string) (string, error) {
c := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, c)
if stdin != "" {
f.stdin[c] = stdin
}
for k, v := range f.fail {
if strings.HasPrefix(c, k) {
return v, errors.New(v)
}
}
for k, v := range f.reply {
if strings.HasPrefix(c, k) {
return v, nil
}
}
return "", nil
}
func (f *fake) lookPath(bin string) (string, error) {
if f.lookup[bin] {
return "/usr/sbin/" + bin, nil
}
return "", errors.New("not found")
}
func (f *fake) has(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func (f *fake) index(prefix string) int {
for i, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return i
}
}
return -1
}
func newManager(f *fake) *Manager {
m := New(f.run)
m.LookPath = f.lookPath
m.Owner, m.UID, m.GID = "ansible", "1000", "1000"
return m
}
func TestApplyOrder(t *testing.T) {
f := newFake()
m := newManager(f)
if err := m.Apply(sample()); err != nil {
t.Fatalf("apply: %v\n%v", err, f.calls)
}
// smb.conf written through sh with the rendered content on stdin
wi := f.index("sh -c")
if wi < 0 || !strings.Contains(f.calls[wi], DefaultSMBConf) {
t.Fatalf("smb.conf not written: %v", f.calls)
}
if !strings.Contains(f.stdin[f.calls[wi]], "[documents]") {
t.Errorf("stdin lacks rendered config")
}
ti, si, ri := f.index("testparm -s"), f.index("systemctl enable --now smbd nmbd"), f.index("smbcontrol all reload-config")
if !(wi < ti && ti < si && si < ri) {
t.Errorf("SMB order wrong: %v", f.calls)
}
ni, ei := f.index("systemctl enable --now nfs-server"), f.index("exportfs -ra")
if ni < 0 || ei < ni {
t.Errorf("NFS order wrong: %v", f.calls)
}
// exports written
found := false
for c, in := range f.stdin {
if strings.Contains(c, DefaultExports) && strings.Contains(in, "crossmnt") {
found = true
}
}
if !found {
t.Errorf("exports not written: %v", f.stdin)
}
}
func TestApplyTestparmFailureRestores(t *testing.T) {
f := newFake()
f.fail["testparm"] = "Error loading services"
m := newManager(f)
err := m.Apply(sample())
if err == nil || !strings.Contains(err.Error(), "Error loading services") {
t.Fatalf("expected testparm error, got %v", err)
}
if f.has("smbcontrol") || f.has("systemctl enable --now smbd") {
t.Errorf("smbd touched after failed testparm: %v", f.calls)
}
restored := false
for _, c := range f.calls {
if strings.HasPrefix(c, "sh -c") && strings.Contains(c, ".bak") && strings.Contains(c, "mv -f") && strings.Contains(c, DefaultSMBConf) && !strings.Contains(c, "cat >") {
restored = true
}
}
if !restored {
t.Errorf("backup not restored: %v", f.calls)
}
// NFS is still applied independently
if !f.has("exportfs -ra") {
t.Errorf("NFS skipped after SMB failure")
}
}
func TestApplyDisablesWhenEmpty(t *testing.T) {
f := newFake()
m := newManager(f)
c := Defaults() // no shares, no exports
if err := m.Apply(c); err != nil {
t.Fatal(err)
}
if !f.has("systemctl disable --now smbd nmbd") || !f.has("systemctl disable --now nfs-server") {
t.Errorf("services not disabled: %v", f.calls)
}
if f.has("smbcontrol") || f.has("exportfs") {
t.Errorf("reload attempted with nothing to serve: %v", f.calls)
}
}
func TestApplyNotInstalled(t *testing.T) {
f := newFake()
f.lookup = map[string]bool{}
m := newManager(f)
if err := m.Apply(sample()); err != nil || len(f.calls) != 0 {
t.Errorf("nothing should run without samba/nfs: %v %v", err, f.calls)
}
}
func TestUsers(t *testing.T) {
f := newFake()
f.reply["pdbedit -L"] = "alice:1001:\nbob:1002:Bob B\n"
f.fail["getent passwd carol"] = ""
f.reply["getent passwd bob"] = "bob:x:1002:1000::/nonexistent:/usr/sbin/nologin"
m := newManager(f)
users, err := m.ListUsers()
if err != nil || strings.Join(users, ",") != "alice,bob" {
t.Fatalf("list: %v %v", users, err)
}
if err := m.SetPassword("carol", "s3cret"); err != nil {
t.Fatal(err)
}
if !f.has("useradd -M -N -g 1000 -s /usr/sbin/nologin") {
t.Errorf("unix user not created: %v", f.calls)
}
if in := f.stdin["smbpasswd -s -a carol"]; in != "s3cret\ns3cret\n" {
t.Errorf("smbpasswd stdin = %q", in)
}
if err := m.SetPassword("Carol", "x"); err == nil {
t.Errorf("bad user name accepted")
}
if err := m.SetPassword("carol", "ab"); err == nil {
t.Errorf("short password accepted")
}
if err := m.DeleteUser("bob"); err != nil {
t.Fatal(err)
}
if !f.has("smbpasswd -x bob") || !f.has("userdel bob") {
t.Errorf("delete: %v", f.calls)
}
// the data owner keeps the unix account
f.reply["getent passwd ansible"] = "ansible:x:1000:1000::/home/ansible:/usr/sbin/nologin"
_ = m.DeleteUser("ansible")
if f.has("userdel ansible") {
t.Errorf("owner account deleted")
}
}
+152
View File
@@ -0,0 +1,152 @@
package shares
import (
"errors"
"fmt"
"net"
"path"
"regexp"
"strings"
)
var (
reShareName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$`)
reWorkgroup = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,14}$`)
reUser = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
reHost = regexp.MustCompile(`^[A-Za-z0-9*][A-Za-z0-9*.-]{0,253}$`)
reComment = regexp.MustCompile(`^[^\r\n\[\]]{0,120}$`)
)
// reserved Samba section names that must not be used as share names.
var reservedShares = map[string]bool{"global": true, "homes": true, "printers": true, "print$": true}
func ValidateShareName(s string) error {
if !reShareName.MatchString(s) {
return errors.New("share name: 1-80 characters, letters, digits, _ . - and must start with a letter or digit")
}
if reservedShares[strings.ToLower(s)] {
return fmt.Errorf("share name %q is reserved by Samba", s)
}
return nil
}
func ValidateWorkgroup(s string) error {
if !reWorkgroup.MatchString(s) {
return errors.New("workgroup: 1-15 characters, letters, digits, _ -")
}
return nil
}
func ValidateUserName(s string) error {
if !reUser.MatchString(s) {
return errors.New("user name: lowercase letters, digits, _ -, up to 32 characters")
}
if s == "root" || s == "nobody" {
return fmt.Errorf("user %s cannot be an SMB user", s)
}
return nil
}
func ValidateComment(s string) error {
if !reComment.MatchString(s) {
return errors.New("comment: up to 120 characters, no [ ] or line breaks")
}
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).
func ValidatePath(p string) error {
if p == "" {
return errors.New("path is required")
}
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
}
// UnderRoots reports whether p equals or lies below one of the roots.
func UnderRoots(p string, roots []string) bool {
for _, r := range roots {
if r == "" || r == "/" {
continue
}
if p == r || strings.HasPrefix(p, r+"/") {
return true
}
}
return false
}
// ValidateClient accepts *, a host name (wildcards allowed), an IP or a CIDR.
func ValidateClient(c string) error {
if c == "*" {
return nil
}
if strings.Contains(c, "/") {
if _, _, err := net.ParseCIDR(c); err != nil {
return fmt.Errorf("client %q: bad network, use e.g. 192.168.1.0/24", c)
}
return nil
}
if net.ParseIP(c) != nil || reHost.MatchString(c) {
return nil
}
return fmt.Errorf("client %q: use a host name, IP address, network (CIDR) or *", c)
}
func (s SMBShare) Validate() error {
if err := ValidateShareName(s.Name); err != nil {
return err
}
if err := ValidatePath(s.Path); err != nil {
return err
}
if err := ValidateComment(s.Comment); err != nil {
return err
}
if !s.Guest && len(s.Users) == 0 {
return fmt.Errorf("share %s: allow guests or pick at least one user", s.Name)
}
for _, u := range s.Users {
if err := ValidateUserName(u); err != nil {
return err
}
}
return nil
}
func (e NFSExport) Validate() error {
if err := ValidatePath(e.Path); err != nil {
return err
}
if len(e.Clients) == 0 {
return fmt.Errorf("export %s: at least one client is required (or *)", e.Path)
}
for _, c := range e.Clients {
if err := ValidateClient(c); err != nil {
return err
}
}
switch e.Squash {
case "root", "all", "none":
default:
return errors.New("squash must be root, all or none")
}
return nil
}
// SplitList turns "a, b;c\nd" into ["a","b","c","d"].
func SplitList(s string) []string {
var out []string
for _, f := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ';' || r == '\n' || r == ' ' || r == '\t' || r == '\r' }) {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}