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>
377 lines
12 KiB
Go
377 lines
12 KiB
Go
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")
|
|
}
|
|
// 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) {
|
|
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", "/mnt/usb", "/srv/media", "/home/alice/docs", "/media/disk", "/variable", "/etcetera"} {
|
|
if err := ValidatePath(p); err != nil {
|
|
t.Errorf("%q rejected: %v", p, err)
|
|
}
|
|
}
|
|
for _, p := range []string{"", "/", "nas/x", "/nas/../etc", "/nas/", "/nas/x\"y", "/nas/x\n",
|
|
"/etc", "/etc/samba", "/var/lib/docker", "/usr", "/proc/1", "/root", "/boot/efi", "/run/user/1000"} {
|
|
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")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|