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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user