From 39370cb6cc4da72ae123379d599e11cb003d065a Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Tue, 14 Apr 2026 11:03:48 +0100 Subject: [PATCH] serve smb: add new command to serve a remote over SMB Add a new `rclone serve smb` command that serves any rclone remote over the SMB protocol using the github.com/macos-fuse-t/go-smb2 server library. Fixes #7596 --- cmd/all/all.go | 1 + cmd/serve/smb/proxy.go | 224 ++++++++++++ cmd/serve/smb/server.go | 110 ++++++ cmd/serve/smb/smb.go | 157 +++++++++ cmd/serve/smb/smb_manual_test.go | 89 +++++ cmd/serve/smb/smb_test.go | 167 +++++++++ cmd/serve/smb/smb_unsupported.go | 12 + cmd/serve/smb/vfs.go | 571 +++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 10 files changed, 1334 insertions(+) create mode 100644 cmd/serve/smb/proxy.go create mode 100644 cmd/serve/smb/server.go create mode 100644 cmd/serve/smb/smb.go create mode 100644 cmd/serve/smb/smb_manual_test.go create mode 100644 cmd/serve/smb/smb_test.go create mode 100644 cmd/serve/smb/smb_unsupported.go create mode 100644 cmd/serve/smb/vfs.go diff --git a/cmd/all/all.go b/cmd/all/all.go index 912cde629..606d9370c 100644 --- a/cmd/all/all.go +++ b/cmd/all/all.go @@ -66,6 +66,7 @@ import ( _ "github.com/rclone/rclone/cmd/serve/restic" _ "github.com/rclone/rclone/cmd/serve/s3" _ "github.com/rclone/rclone/cmd/serve/sftp" + _ "github.com/rclone/rclone/cmd/serve/smb" _ "github.com/rclone/rclone/cmd/serve/webdav" _ "github.com/rclone/rclone/cmd/settier" _ "github.com/rclone/rclone/cmd/sha1sum" diff --git a/cmd/serve/smb/proxy.go b/cmd/serve/smb/proxy.go new file mode 100644 index 000000000..0306edeaa --- /dev/null +++ b/cmd/serve/smb/proxy.go @@ -0,0 +1,224 @@ +//go:build !windows && !plan9 + +package smb + +import ( + "sync" + "syscall" + + "github.com/macos-fuse-t/go-smb2/vfs" + "github.com/rclone/rclone/cmd/serve/proxy" + rclonefs "github.com/rclone/rclone/fs" +) + +// proxySMBVFS wraps smbVFS with lazy initialization via the auth proxy. +// +// NTLM authentication doesn't provide plaintext passwords, so we +// can't do per-user auth proxy dispatch the way HTTP/SFTP can. Instead +// we allow guest access at the NTLM level and call the proxy once +// (with the configured user/pass) to obtain a VFS. All SMB sessions +// share that VFS. +type proxySMBVFS struct { + proxy *proxy.Proxy + user string + pass string + + once sync.Once + vfs *smbVFS + err error +} + +func newProxySMBVFS(p *proxy.Proxy, user, pass string) *proxySMBVFS { + return &proxySMBVFS{ + proxy: p, + user: user, + pass: pass, + } +} + +// init lazily creates the underlying smbVFS by calling the proxy +func (p *proxySMBVFS) init() (*smbVFS, error) { + p.once.Do(func() { + user := p.user + if user == "" { + user = "anonymous" + } + pass := p.pass + if pass == "" { + pass = "anonymous" + } + VFS, _, err := p.proxy.Call(user, pass, false) + if err != nil { + rclonefs.Errorf(nil, "serve smb: auth proxy call failed: %v", err) + p.err = err + return + } + p.vfs = newSMBVFS(VFS) + }) + return p.vfs, p.err +} + +// All VFSFileSystem methods delegate to the lazily-initialized smbVFS. + +func (p *proxySMBVFS) GetAttr(h vfs.VfsHandle) (*vfs.Attributes, error) { + s, err := p.init() + if err != nil { + return nil, syscall.EIO + } + return s.GetAttr(h) +} + +func (p *proxySMBVFS) SetAttr(h vfs.VfsHandle, a *vfs.Attributes) (*vfs.Attributes, error) { + s, err := p.init() + if err != nil { + return nil, syscall.EIO + } + return s.SetAttr(h, a) +} + +func (p *proxySMBVFS) StatFS(h vfs.VfsHandle) (*vfs.FSAttributes, error) { + s, err := p.init() + if err != nil { + return nil, syscall.EIO + } + return s.StatFS(h) +} + +func (p *proxySMBVFS) FSync(h vfs.VfsHandle) error { + s, err := p.init() + if err != nil { + return syscall.EIO + } + return s.FSync(h) +} + +func (p *proxySMBVFS) Flush(h vfs.VfsHandle) error { + s, err := p.init() + if err != nil { + return syscall.EIO + } + return s.Flush(h) +} + +func (p *proxySMBVFS) Open(name string, flags int, mode int) (vfs.VfsHandle, error) { + s, err := p.init() + if err != nil { + return 0, syscall.EIO + } + return s.Open(name, flags, mode) +} + +func (p *proxySMBVFS) Close(h vfs.VfsHandle) error { + s, err := p.init() + if err != nil { + return syscall.EIO + } + return s.Close(h) +} + +func (p *proxySMBVFS) Lookup(h vfs.VfsHandle, name string) (*vfs.Attributes, error) { + s, err := p.init() + if err != nil { + return nil, syscall.EIO + } + return s.Lookup(h, name) +} + +func (p *proxySMBVFS) Mkdir(name string, mode int) (*vfs.Attributes, error) { + s, err := p.init() + if err != nil { + return nil, syscall.EIO + } + return s.Mkdir(name, mode) +} + +func (p *proxySMBVFS) Read(h vfs.VfsHandle, buf []byte, offset uint64, flags int) (int, error) { + s, err := p.init() + if err != nil { + return 0, syscall.EIO + } + return s.Read(h, buf, offset, flags) +} + +func (p *proxySMBVFS) Write(h vfs.VfsHandle, data []byte, offset uint64, flags int) (int, error) { + s, err := p.init() + if err != nil { + return 0, syscall.EIO + } + return s.Write(h, data, offset, flags) +} + +func (p *proxySMBVFS) OpenDir(name string) (vfs.VfsHandle, error) { + s, err := p.init() + if err != nil { + return 0, syscall.EIO + } + return s.OpenDir(name) +} + +func (p *proxySMBVFS) ReadDir(h vfs.VfsHandle, pos int, count int) ([]vfs.DirInfo, error) { + s, err := p.init() + if err != nil { + return nil, syscall.EIO + } + return s.ReadDir(h, pos, count) +} + +func (p *proxySMBVFS) Readlink(h vfs.VfsHandle) (string, error) { + s, err := p.init() + if err != nil { + return "", syscall.EIO + } + return s.Readlink(h) +} + +func (p *proxySMBVFS) Unlink(h vfs.VfsHandle) error { + s, err := p.init() + if err != nil { + return syscall.EIO + } + return s.Unlink(h) +} + +func (p *proxySMBVFS) Truncate(h vfs.VfsHandle, size uint64) error { + s, err := p.init() + if err != nil { + return syscall.EIO + } + return s.Truncate(h, size) +} + +func (p *proxySMBVFS) Rename(h vfs.VfsHandle, newPath string, flags int) error { + s, err := p.init() + if err != nil { + return syscall.EIO + } + return s.Rename(h, newPath, flags) +} + +func (p *proxySMBVFS) Symlink(h vfs.VfsHandle, target string, flags int) (*vfs.Attributes, error) { + return nil, syscall.ENOSYS +} + +func (p *proxySMBVFS) Link(from vfs.VfsNode, to vfs.VfsNode, name string) (*vfs.Attributes, error) { + return nil, syscall.ENOSYS +} + +func (p *proxySMBVFS) Listxattr(h vfs.VfsHandle) ([]string, error) { + return nil, nil +} + +func (p *proxySMBVFS) Getxattr(h vfs.VfsHandle, name string, buf []byte) (int, error) { + return 0, syscall.ENOENT +} + +func (p *proxySMBVFS) Setxattr(h vfs.VfsHandle, name string, data []byte) error { + return syscall.ENOSYS +} + +func (p *proxySMBVFS) Removexattr(h vfs.VfsHandle, name string) error { + return syscall.ENOSYS +} + +// Check that proxySMBVFS implements VFSFileSystem +var _ vfs.VFSFileSystem = (*proxySMBVFS)(nil) diff --git a/cmd/serve/smb/server.go b/cmd/serve/smb/server.go new file mode 100644 index 000000000..3e5eb702b --- /dev/null +++ b/cmd/serve/smb/server.go @@ -0,0 +1,110 @@ +//go:build !windows && !plan9 + +package smb + +import ( + "context" + "errors" + "fmt" + "net" + + smbserver "github.com/macos-fuse-t/go-smb2/server" + smbvfs "github.com/macos-fuse-t/go-smb2/vfs" + "github.com/rclone/rclone/cmd/serve/proxy" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/vfs" + "github.com/rclone/rclone/vfs/vfscommon" +) + +// server contains everything to run the SMB server +type server struct { + f fs.Fs + opt Options + vfs *vfs.VFS + ctx context.Context // for global config + proxy *proxy.Proxy + listener net.Listener + srv *smbserver.Server + stopped chan struct{} // for waiting on the server to stop +} + +func newServer(ctx context.Context, f fs.Fs, opt *Options, vfsOpt *vfscommon.Options, proxyOpt *proxy.Options) (*server, error) { + s := &server{ + f: f, + ctx: ctx, + opt: *opt, + stopped: make(chan struct{}), + } + if proxyOpt.AuthProxy != "" { + s.proxy = proxy.New(ctx, proxyOpt, vfsOpt) + } else { + s.vfs = vfs.New(ctx, f, vfsOpt) + } + + if !s.opt.NoAuth && s.opt.User == "" && s.opt.Pass == "" && s.proxy == nil { + return nil, errors.New("no authorization found, use --user/--pass or --no-auth or --auth-proxy") + } + + // Listen on the configured address + var err error + s.listener, err = net.Listen("tcp", s.opt.ListenAddr) + if err != nil { + return nil, fmt.Errorf("failed to listen: %w", err) + } + + // Create the SMB VFS bridge. + // When using auth proxy, use a proxy-aware VFS that lazily calls + // the proxy to get a VFS on the first operation. NTLM doesn't + // provide plaintext passwords so we can't do per-user auth proxy + // in the same way as HTTP/SFTP. Instead we allow guest access + // at the NTLM level and let the proxy create the VFS. + var smbFS smbvfs.VFSFileSystem + if s.proxy != nil { + smbFS = newProxySMBVFS(s.proxy, s.opt.User, s.opt.Pass) + } else { + smbFS = newSMBVFS(s.vfs) + } + + // Create shares map + shares := map[string]smbvfs.VFSFileSystem{ + s.opt.ShareName: smbFS, + } + + // Create NTLM authenticator + auth := &smbserver.NTLMAuthenticator{ + UserPassword: map[string]string{}, + AllowGuest: s.opt.NoAuth || s.proxy != nil, + } + if s.opt.User != "" && s.opt.Pass != "" { + auth.UserPassword[s.opt.User] = s.opt.Pass + } + + // Configure the SMB server + cfg := &smbserver.ServerConfig{ + AllowGuest: s.opt.NoAuth || s.proxy != nil, + } + + s.srv = smbserver.NewServer(cfg, auth, shares) + + return s, nil +} + +// Serve starts the SMB server - blocks until Shutdown is called +func (s *server) Serve() error { + fs.Logf(nil, "SMB server listening on %v\n", s.listener.Addr()) + err := s.srv.ServeListener(s.listener) + close(s.stopped) + return err +} + +// Addr returns the address the server is listening on +func (s *server) Addr() net.Addr { + return s.listener.Addr() +} + +// Shutdown stops the SMB server +func (s *server) Shutdown() error { + s.srv.Shutdown() + <-s.stopped + return nil +} diff --git a/cmd/serve/smb/smb.go b/cmd/serve/smb/smb.go new file mode 100644 index 000000000..5fa0161cd --- /dev/null +++ b/cmd/serve/smb/smb.go @@ -0,0 +1,157 @@ +//go:build !windows && !plan9 + +// Package smb implements an SMB server to serve an rclone VFS +package smb + +import ( + "context" + "fmt" + "strings" + + "github.com/rclone/rclone/cmd" + "github.com/rclone/rclone/cmd/serve" + "github.com/rclone/rclone/cmd/serve/proxy" + "github.com/rclone/rclone/cmd/serve/proxy/proxyflags" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config/configstruct" + "github.com/rclone/rclone/fs/config/flags" + "github.com/rclone/rclone/fs/rc" + "github.com/rclone/rclone/lib/systemd" + "github.com/rclone/rclone/vfs" + "github.com/rclone/rclone/vfs/vfscommon" + "github.com/rclone/rclone/vfs/vfsflags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// OptionsInfo describes the Options in use +var OptionsInfo = fs.Options{{ + Name: "addr", + Default: "localhost:445", + Help: "IPaddress:Port or :Port to bind server to", +}, { + Name: "user", + Default: "", + Help: "User name for authentication", +}, { + Name: "pass", + Default: "", + Help: "Password for authentication", +}, { + Name: "no_auth", + Default: false, + Help: "Allow connections with no authentication if set", +}, { + Name: "share_name", + Default: "rclone", + Help: "Name of the SMB share", +}} + +// Options contains options for the SMB server +type Options struct { + ListenAddr string `config:"addr"` // Port to listen on + User string `config:"user"` // single username + Pass string `config:"pass"` // password for user + NoAuth bool `config:"no_auth"` // allow no authentication on connections + ShareName string `config:"share_name"` // name of the SMB share +} + +func init() { + fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "smb-serve", Opt: &Opt, Options: OptionsInfo}) +} + +// Opt is options set by command line flags +var Opt Options + +// AddFlags adds flags for the SMB server +func AddFlags(flagSet *pflag.FlagSet, Opt *Options) { + flags.AddFlagsFromOptions(flagSet, "", OptionsInfo) +} + +func init() { + vfsflags.AddFlags(Command.Flags()) + proxyflags.AddFlags(Command.Flags()) + AddFlags(Command.Flags(), &Opt) + serve.Command.AddCommand(Command) + serve.AddRc("smb", func(ctx context.Context, f fs.Fs, in rc.Params) (serve.Handle, error) { + // Read VFS Opts + var vfsOpt = vfscommon.Opt // set default opts + err := configstruct.SetAny(in, &vfsOpt) + if err != nil { + return nil, err + } + // Read Proxy Opts + var proxyOpt = proxy.Opt // set default opts + err = configstruct.SetAny(in, &proxyOpt) + if err != nil { + return nil, err + } + // Read opts + var opt = Opt // set default opts + err = configstruct.SetAny(in, &opt) + if err != nil { + return nil, err + } + // Create server + return newServer(ctx, f, &opt, &vfsOpt, &proxyOpt) + }) +} + +// Command definition for cobra +var Command = &cobra.Command{ + Use: "smb remote:path", + Short: `Serve the remote over SMB.`, + Long: `Run an SMB server to serve a remote over SMB. This can be used +with an SMB client or you can make a remote of type [smb](/smb) to use with it. + +You can use the [filter](/filtering) flags (e.g. ` + "`--include`, `--exclude`" + `) +to control what is served. + +The server will log errors. Use ` + "`-v`" + ` to see access logs. + +` + "`--bwlimit`" + ` will be respected for file transfers. +Use ` + "`--stats`" + ` to control the stats printing. + +You must provide some means of authentication, either with +` + "`--user`/`--pass`" + `, an ` + "`--auth-proxy`" + `, or set the ` + "`--no-auth`" + ` flag for no +authentication when logging in. + +By default the server binds to localhost:445 - if you want it to be +reachable externally then supply ` + "`--addr :445`" + ` for example. + +Note that port 445 typically requires root/administrator privileges. +Use ` + "`--addr :1445`" + ` or similar to use a non-privileged port. + +The remote will be served as a single SMB share. The share name +defaults to "rclone" and can be changed with ` + "`--share-name`" + `. + +Note that the default of ` + "`--vfs-cache-mode off`" + ` is fine for the rclone +smb backend, but it may not be with other SMB clients. + +This command uses the library https://github.com/macos-fuse-t/go-smb2 +which the author has given special consent for rclone to build and +distribute under the rclone licensing terms. + +` + strings.TrimSpace(vfs.Help()+proxy.Help), + Annotations: map[string]string{ + "versionIntroduced": "v1.74", + "groups": "Filter", + }, + Run: func(command *cobra.Command, args []string) { + var f fs.Fs + if proxy.Opt.AuthProxy == "" { + cmd.CheckArgs(1, 1, command, args) + f = cmd.NewFsSrc(args) + } else { + cmd.CheckArgs(0, 0, command, args) + } + cmd.Run(false, true, command, func() error { + s, err := newServer(context.Background(), f, &Opt, &vfscommon.Opt, &proxy.Opt) + if err != nil { + fs.Fatal(nil, fmt.Sprint(err)) + } + defer systemd.Notify()() + return s.Serve() + }) + }, +} diff --git a/cmd/serve/smb/smb_manual_test.go b/cmd/serve/smb/smb_manual_test.go new file mode 100644 index 000000000..9270a0ad5 --- /dev/null +++ b/cmd/serve/smb/smb_manual_test.go @@ -0,0 +1,89 @@ +//go:build !windows && !darwin && !plan9 + +package smb + +import ( + "context" + "os" + "testing" + "time" + + smb2client "github.com/cloudsoda/go-smb2" + _ "github.com/rclone/rclone/backend/local" + "github.com/rclone/rclone/cmd/serve/proxy" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/vfs/vfscommon" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSMBManualNoAuth(t *testing.T) { + testSMBManual(t, "", "", true) +} + +func TestSMBManualAuth(t *testing.T) { + testSMBManual(t, "testuser", "testpass", false) +} + +func testSMBManual(t *testing.T, user, pass string, noAuth bool) { + dir := t.TempDir() + err := os.WriteFile(dir+"/hello.txt", []byte("hello world"), 0644) + require.NoError(t, err) + + f, err := fs.NewFs(context.Background(), dir) + require.NoError(t, err) + + opt := Options{ + ListenAddr: "localhost:0", + User: user, + Pass: pass, + NoAuth: noAuth, + ShareName: "rclone", + } + s, err := newServer(context.Background(), f, &opt, &vfscommon.Opt, &proxy.Opt) + require.NoError(t, err) + + go func() { + _ = s.Serve() + }() + + addr := s.Addr().String() + t.Logf("Server listening on %s", addr) + time.Sleep(200 * time.Millisecond) + + clientUser := user + clientPass := pass + if noAuth { + clientUser = "guest" + clientPass = "" + } + + d := &smb2client.Dialer{ + Initiator: &smb2client.NTLMInitiator{ + User: clientUser, + Password: clientPass, + }, + } + + session, err := d.Dial(context.Background(), addr) + require.NoError(t, err, "SMB dial failed") + + share, err := session.Mount("rclone") + require.NoError(t, err, "SMB mount failed") + + entries, err := share.ReadDir(".") + require.NoError(t, err, "ReadDir failed") + require.Len(t, entries, 1) + assert.Equal(t, "hello.txt", entries[0].Name()) + + data, err := share.ReadFile("hello.txt") + require.NoError(t, err, "ReadFile failed") + assert.Equal(t, "hello world", string(data)) + + err = share.WriteFile("test.txt", []byte("test data"), 0644) + require.NoError(t, err, "WriteFile failed") + + _ = share.Umount() + _ = session.Logoff() + assert.NoError(t, s.Shutdown()) +} diff --git a/cmd/serve/smb/smb_test.go b/cmd/serve/smb/smb_test.go new file mode 100644 index 000000000..e4d065ffb --- /dev/null +++ b/cmd/serve/smb/smb_test.go @@ -0,0 +1,167 @@ +// Serve smb tests set up a server and run the integration tests +// for the smb remote against it. +// +// We skip tests on platforms with troublesome character mappings + +//go:build !windows && !darwin && !plan9 + +package smb + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + _ "github.com/rclone/rclone/backend/local" + "github.com/rclone/rclone/cmd/serve/proxy" + "github.com/rclone/rclone/cmd/serve/servetest" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config/configmap" + "github.com/rclone/rclone/fs/config/obscure" + "github.com/rclone/rclone/fs/rc" + "github.com/rclone/rclone/fstest" + "github.com/rclone/rclone/vfs/vfscommon" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testBindAddress = "localhost:0" + testUser = "testuser" + testPass = "testpass" +) + +// startServer creates and starts an SMB server for testing. +// It returns a configmap for the SMB backend to connect and a cleanup function. +func startServer(t *testing.T, f fs.Fs) (configmap.Simple, func()) { + opt := Opt + opt.ListenAddr = testBindAddress + opt.User = testUser + opt.Pass = testPass + opt.ShareName = "rclone" + + // Use writes cache mode so that random-access writes (OpenWriterAt) work + vfsOpt := vfscommon.Opt + vfsOpt.CacheMode = vfscommon.CacheModeWrites + + w, err := newServer(context.Background(), f, &opt, &vfsOpt, &proxy.Opt) + require.NoError(t, err) + go func() { + require.NoError(t, w.Serve()) + }() + + // Read the host and port we started on + addr := w.Addr().String() + colon := strings.LastIndex(addr, ":") + + // Config for the backend we'll use to connect to the server + config := configmap.Simple{ + "type": "smb", + "user": testUser, + "pass": obscure.MustObscure(testPass), + "host": addr[:colon], + "port": addr[colon+1:], + } + + return config, func() { + assert.NoError(t, w.Shutdown()) + } +} + +// TestSmb runs the smb server then runs the unit tests for the +// smb remote against it. +// +// The SMB backend is bucket-based (share = bucket) and requires the +// share name in the remote path. servetest.Run passes "servesmbtest:" +// which has no share name, so we use a custom test runner that passes +// "servesmbtest:rclone" instead. +func TestSmb(t *testing.T) { + fstest.Initialise() + + t.Run("Normal", func(t *testing.T) { + runSMBBackendTests(t, false) + }) + t.Run("AuthProxy", func(t *testing.T) { + runSMBBackendTests(t, true) + }) +} + +// runSMBBackendTests starts the server and runs the SMB backend integration tests +// with the share name included in the remote path. +func runSMBBackendTests(t *testing.T, useProxy bool) { + fremote, _, clean, err := fstest.RandomRemote() + assert.NoError(t, err) + defer clean() + + err = fremote.Mkdir(context.Background(), "") + assert.NoError(t, err) + + f := fremote + if useProxy { + // If using a proxy don't pass in the backend + f = nil + + // the backend config will be made by the proxy + prog, err := filepath.Abs("../servetest/proxy_code.go") + require.NoError(t, err) + cmd := "go run " + prog + " " + fremote.Root() + + // FIXME this is untidy setting a global variable! + proxy.Opt.AuthProxy = cmd + defer func() { + proxy.Opt.AuthProxy = "" + }() + } + config, cleanup := startServer(t, f) + defer cleanup() + + // Change directory to run the tests + cwd, err := os.Getwd() + require.NoError(t, err) + err = os.Chdir("../../../backend/smb") + require.NoError(t, err, "failed to cd to smb backend") + defer func() { + require.NoError(t, os.Chdir(cwd)) + }() + + // Run the backend tests with the share name in the remote path. + // The SMB backend expects "remoteName:shareName" format. + args := []string{"test"} + if testing.Verbose() { + args = append(args, "-v") + } + if *fstest.Verbose { + args = append(args, "-verbose") + } + remoteName := "servesmbtest:rclone" + args = append(args, "-remote", remoteName) + args = append(args, "-list-retries", fmt.Sprint(*fstest.ListRetries)) + cmd := exec.Command("go", args...) + + // Configure the backend with environment variables + cmd.Env = os.Environ() + prefix := "RCLONE_CONFIG_SERVESMBTEST_" + for k, v := range config { + cmd.Env = append(cmd.Env, prefix+strings.ToUpper(k)+"="+v) + } + + // Run the test + out, err := cmd.CombinedOutput() + if len(out) != 0 { + t.Logf("\n----------\n%s----------\n", string(out)) + } + assert.NoError(t, err, "Running smb integration tests") +} + +func TestRc(t *testing.T) { + servetest.TestRc(t, rc.Params{ + "type": "smb", + "user": "test", + "pass": obscure.MustObscure("test"), + "vfs_cache_mode": "off", + }) +} diff --git a/cmd/serve/smb/smb_unsupported.go b/cmd/serve/smb/smb_unsupported.go new file mode 100644 index 000000000..2f98cada2 --- /dev/null +++ b/cmd/serve/smb/smb_unsupported.go @@ -0,0 +1,12 @@ +// For unsupported platforms (windows, plan9) +//go:build windows || plan9 + +// Package smb is not supported on Windows or Plan 9 +package smb + +import ( + "github.com/spf13/cobra" +) + +// Command is just nil for unsupported platforms +var Command *cobra.Command diff --git a/cmd/serve/smb/vfs.go b/cmd/serve/smb/vfs.go new file mode 100644 index 000000000..db473ee61 --- /dev/null +++ b/cmd/serve/smb/vfs.go @@ -0,0 +1,571 @@ +//go:build !windows && !plan9 + +// Package smb implements a server to serve a VFS remote over SMB +package smb + +import ( + "io" + "os" + "path" + "strings" + "sync" + "sync/atomic" + "syscall" + + "github.com/macos-fuse-t/go-smb2/vfs" + rclonefs "github.com/rclone/rclone/fs" + rclonevfs "github.com/rclone/rclone/vfs" +) + +// smbVFS bridges the macos-fuse-t/go-smb2 VFSFileSystem interface +// to the rclone VFS. +type smbVFS struct { + vfs *rclonevfs.VFS + + mu sync.RWMutex + handles map[vfs.VfsHandle]*smbHandle + nextID atomic.Uint64 +} + +type smbHandle struct { + path string + file rclonevfs.Handle // open file handle (nil for dirs) + dir *rclonevfs.Dir // open dir (nil for files) + node rclonevfs.Node // underlying VFS node + dirPos int // current position in directory listing (for stateful ReadDir) +} + +func newSMBVFS(vfsInst *rclonevfs.VFS) *smbVFS { + return &smbVFS{ + vfs: vfsInst, + handles: make(map[vfs.VfsHandle]*smbHandle), + } +} + +// allocHandle stores a handle and returns its ID +func (s *smbVFS) allocHandle(h *smbHandle) vfs.VfsHandle { + id := vfs.VfsHandle(s.nextID.Add(1)) + s.mu.Lock() + s.handles[id] = h + s.mu.Unlock() + return id +} + +// getHandle retrieves a handle by ID; handle 0 is the root +func (s *smbVFS) getHandle(id vfs.VfsHandle) *smbHandle { + if id == 0 { + root, err := s.vfs.Root() + if err != nil { + return nil + } + return &smbHandle{ + path: "", + dir: root, + node: root, + } + } + s.mu.RLock() + h := s.handles[id] + s.mu.RUnlock() + return h +} + +// freeHandle removes a handle from the table +func (s *smbVFS) freeHandle(id vfs.VfsHandle) { + s.mu.Lock() + delete(s.handles, id) + s.mu.Unlock() +} + +// cleanPath cleans an SMB path for use with the VFS +func cleanPath(p string) string { + p = strings.ReplaceAll(p, "\\", "/") + p = path.Clean("/" + p) + if p == "/" { + return "" + } + // Remove leading slash - rclone VFS uses relative paths + return p[1:] +} + +// nodeToAttrs converts a VFS node to SMB Attributes. +// Node embeds os.FileInfo, so the node itself is the FileInfo. +func nodeToAttrs(node rclonevfs.Node) *vfs.Attributes { + a := &vfs.Attributes{} + a.SetSizeBytes(uint64(node.Size())) + a.SetDiskSizeBytes(uint64(node.Size())) + a.SetLastDataModificationTime(node.ModTime()) + a.SetAccessTime(node.ModTime()) + a.SetLastStatusChangeTime(node.ModTime()) + a.SetBirthTime(node.ModTime()) + a.SetLinkCount(1) + + if node.IsDir() { + a.SetFileType(vfs.FileTypeDirectory) + a.SetPermissions(vfs.PermissionsRead | vfs.PermissionsWrite | vfs.PermissionsExecute) + } else { + a.SetFileType(vfs.FileTypeRegularFile) + a.SetPermissions(vfs.PermissionsRead | vfs.PermissionsWrite) + } + + inode := node.Inode() + a.SetInodeNumber(inode) + a.SetFileHandle(vfs.VfsNode(inode)) + a.SetChangeID(uint64(node.ModTime().UnixNano())) + + return a +} + +// convertError maps common errors to syscall errors that the SMB +// library translates to NT status codes +func convertError(err error) error { + if err == nil { + return nil + } + switch { + case err == rclonevfs.ENOENT || os.IsNotExist(err): + return syscall.ENOENT + case err == rclonevfs.EEXIST || os.IsExist(err): + return syscall.EEXIST + case err == rclonevfs.EPERM || os.IsPermission(err): + return syscall.EACCES + case err == rclonevfs.ENOTEMPTY: + return syscall.ENOTEMPTY + case err == rclonevfs.EINVAL: + return syscall.EINVAL + case err == rclonevfs.ENOSYS: + return syscall.ENOSYS + } + return err +} + +// --- VFSFileSystem interface --- + +// GetAttr returns attributes for an open handle +func (s *smbVFS) GetAttr(handle vfs.VfsHandle) (*vfs.Attributes, error) { + h := s.getHandle(handle) + if h == nil { + return nil, syscall.EBADF + } + if h.dir != nil { + return nodeToAttrs(h.dir), nil + } + if h.file != nil { + // Get the node from the file handle + node := h.file.Node() + if node != nil { + return nodeToAttrs(node), nil + } + // Fall back to stat by path + n, err := s.vfs.Stat(h.path) + if err != nil { + return nil, convertError(err) + } + return nodeToAttrs(n), nil + } + if h.node != nil { + return nodeToAttrs(h.node), nil + } + return nil, syscall.EBADF +} + +// SetAttr sets attributes on an open handle +func (s *smbVFS) SetAttr(handle vfs.VfsHandle, attrs *vfs.Attributes) (*vfs.Attributes, error) { + h := s.getHandle(handle) + if h == nil { + return nil, syscall.EBADF + } + + // Handle size change (truncation) + if size, ok := attrs.GetSizeBytes(); ok { + if h.file != nil { + if err := h.file.Truncate(int64(size)); err != nil { + return nil, convertError(err) + } + } + } + + // Handle mtime change + if mtime, ok := attrs.GetLastDataModificationTime(); ok { + var node rclonevfs.Node + if h.dir != nil { + node = h.dir + } else if h.node != nil { + node = h.node + } else if h.file != nil { + node = h.file.Node() + } + if node == nil { + n, err := s.vfs.Stat(h.path) + if err == nil { + node = n + } + } + if node != nil { + if err := node.SetModTime(mtime); err != nil { + rclonefs.Debugf(nil, "SetAttr: failed to set mtime on %q: %v", h.path, err) + } + } + } + + return s.GetAttr(handle) +} + +// StatFS returns filesystem statistics +func (s *smbVFS) StatFS(_ vfs.VfsHandle) (*vfs.FSAttributes, error) { + const blockSize = 4096 + + total, used, free := s.vfs.Statfs() + + fsa := &vfs.FSAttributes{} + fsa.SetBlockSize(blockSize) + fsa.SetIOSize(blockSize) + + if total < 0 { + total = 1 << 40 // 1 TiB default + } + if free < 0 { + free = 1 << 40 + } + _ = used + + fsa.SetBlocks(uint64(total) / blockSize) + fsa.SetFreeBlocks(uint64(free) / blockSize) + fsa.SetAvailableBlocks(uint64(free) / blockSize) + fsa.SetFiles(1000000) + fsa.SetFreeFiles(1000000) + + return fsa, nil +} + +// FSync syncs an open file handle +func (s *smbVFS) FSync(handle vfs.VfsHandle) error { + h := s.getHandle(handle) + if h == nil { + return syscall.EBADF + } + if h.file != nil { + return convertError(h.file.Sync()) + } + return nil +} + +// Flush flushes an open file handle +func (s *smbVFS) Flush(handle vfs.VfsHandle) error { + h := s.getHandle(handle) + if h == nil { + return syscall.EBADF + } + if h.file != nil { + return convertError(h.file.Flush()) + } + return nil +} + +// Open opens a file and returns a handle. +// Falls back to case-insensitive matching for SMB compatibility. +func (s *smbVFS) Open(name string, flags int, mode int) (vfs.VfsHandle, error) { + name = cleanPath(name) + + // Strip lock flags that the SMB server adds + flags &^= (oShlock | oExlock | 0x200000) + + fh, err := s.vfs.OpenFile(name, flags, os.FileMode(mode)) + if err != nil { + // Try case-insensitive lookup and open with the real name + if node, err2 := s.statCaseInsensitive(name); err2 == nil { + realPath := node.Path() + fh, err = s.vfs.OpenFile(realPath, flags, os.FileMode(mode)) + if err == nil { + name = realPath + } + } + if err != nil { + return 0, convertError(err) + } + } + + h := &smbHandle{ + path: name, + file: fh, + node: fh.Node(), + } + return s.allocHandle(h), nil +} + +// Close closes an open handle +func (s *smbVFS) Close(handle vfs.VfsHandle) error { + h := s.getHandle(handle) + if h == nil { + return syscall.EBADF + } + s.freeHandle(handle) + if h.file != nil { + return convertError(h.file.Close()) + } + return nil +} + +// Lookup looks up a child by name in the parent handle's directory. +// When parentHandle is 0, name is treated as a path from the root. +// Lookup is case-insensitive to match Windows SMB behavior. +func (s *smbVFS) Lookup(parentHandle vfs.VfsHandle, name string) (*vfs.Attributes, error) { + var fullPath string + if parentHandle == 0 { + fullPath = cleanPath(name) + } else { + parent := s.getHandle(parentHandle) + if parent == nil { + return nil, syscall.EBADF + } + fullPath = cleanPath(path.Join(parent.path, name)) + } + + // Try exact match first + node, err := s.vfs.Stat(fullPath) + if err == nil { + return nodeToAttrs(node), nil + } + + // Fall back to case-insensitive lookup + node, err = s.statCaseInsensitive(fullPath) + if err != nil { + return nil, convertError(err) + } + return nodeToAttrs(node), nil +} + +// statCaseInsensitive does a case-insensitive stat by resolving each +// path component case-insensitively through directory listings. +func (s *smbVFS) statCaseInsensitive(fullPath string) (rclonevfs.Node, error) { + if fullPath == "" { + return s.vfs.Root() + } + + parts := strings.Split(fullPath, "/") + root, err := s.vfs.Root() + if err != nil { + return nil, err + } + var current rclonevfs.Node = root + + for _, part := range parts { + dir, ok := current.(*rclonevfs.Dir) + if !ok { + return nil, rclonevfs.ENOENT + } + + entries, err := dir.ReadDirAll() + if err != nil { + return nil, err + } + + found := false + for _, entry := range entries { + if strings.EqualFold(entry.Name(), part) { + current = entry + found = true + break + } + } + if !found { + return nil, rclonevfs.ENOENT + } + } + return current, nil +} + +// Mkdir creates a new directory +func (s *smbVFS) Mkdir(name string, _ int) (*vfs.Attributes, error) { + name = cleanPath(name) + dir, leaf := path.Split(name) + dir = strings.TrimSuffix(dir, "/") + + parentNode, err := s.vfs.Stat(dir) + if err != nil { + return nil, convertError(err) + } + parentDir, ok := parentNode.(*rclonevfs.Dir) + if !ok { + return nil, syscall.ENOTDIR + } + if _, err := parentDir.Mkdir(leaf); err != nil { + return nil, convertError(err) + } + node, err := s.vfs.Stat(name) + if err != nil { + return nil, convertError(err) + } + return nodeToAttrs(node), nil +} + +// Read reads from an open file at the given offset +func (s *smbVFS) Read(handle vfs.VfsHandle, buf []byte, offset uint64, _ int) (int, error) { + h := s.getHandle(handle) + if h == nil || h.file == nil { + return 0, syscall.EBADF + } + n, err := h.file.ReadAt(buf, int64(offset)) + if err == io.EOF && n > 0 { + // Partial read with EOF - return data without error + return n, nil + } + return n, convertError(err) +} + +// Write writes to an open file at the given offset +func (s *smbVFS) Write(handle vfs.VfsHandle, data []byte, offset uint64, _ int) (int, error) { + h := s.getHandle(handle) + if h == nil || h.file == nil { + return 0, syscall.EBADF + } + n, err := h.file.WriteAt(data, int64(offset)) + return n, convertError(err) +} + +// OpenDir opens a directory and returns a handle. +// Falls back to case-insensitive matching for SMB compatibility. +func (s *smbVFS) OpenDir(name string) (vfs.VfsHandle, error) { + name = cleanPath(name) + node, err := s.vfs.Stat(name) + if err != nil { + // Try case-insensitive lookup + node, err = s.statCaseInsensitive(name) + if err != nil { + return 0, convertError(err) + } + } + dir, ok := node.(*rclonevfs.Dir) + if !ok { + return 0, syscall.ENOTDIR + } + h := &smbHandle{ + path: dir.Path(), + dir: dir, + node: dir, + } + return s.allocHandle(h), nil +} + +// ReadDir reads directory entries. +// The pos parameter: 0 = continue from current position, 1 = restart from beginning. +// The count parameter limits the number of entries returned. +// Returns io.EOF when no more entries are available. +func (s *smbVFS) ReadDir(handle vfs.VfsHandle, pos int, count int) ([]vfs.DirInfo, error) { + h := s.getHandle(handle) + if h == nil || h.dir == nil { + return nil, syscall.EBADF + } + + // pos=1 means RESTART_SCANS - restart from the beginning + if pos == 1 { + h.dirPos = 0 + } + + nodes, err := h.dir.ReadDirAll() + if err != nil { + return nil, convertError(err) + } + + if h.dirPos >= len(nodes) { + return nil, io.EOF + } + + end := h.dirPos + count + if end > len(nodes) { + end = len(nodes) + } + slice := nodes[h.dirPos:end] + h.dirPos = end + + entries := make([]vfs.DirInfo, 0, len(slice)) + for _, node := range slice { + attrs := nodeToAttrs(node) + entries = append(entries, vfs.DirInfo{ + Name: node.Name(), + Attributes: *attrs, + }) + } + return entries, nil +} + +// Readlink reads the target of a symbolic link (not supported) +func (s *smbVFS) Readlink(_ vfs.VfsHandle) (string, error) { + return "", syscall.ENOSYS +} + +// Unlink removes a file or directory by handle +func (s *smbVFS) Unlink(handle vfs.VfsHandle) error { + h := s.getHandle(handle) + if h == nil { + return syscall.EBADF + } + if h.path == "" { + return syscall.EPERM + } + + // Look up fresh to get current state + node, err := s.vfs.Stat(h.path) + if err != nil { + return convertError(err) + } + return convertError(node.Remove()) +} + +// Truncate truncates a file to the given size +func (s *smbVFS) Truncate(handle vfs.VfsHandle, size uint64) error { + h := s.getHandle(handle) + if h == nil || h.file == nil { + return syscall.EBADF + } + return convertError(h.file.Truncate(int64(size))) +} + +// Rename renames/moves a file. The newPath parameter is relative to the share root. +func (s *smbVFS) Rename(handle vfs.VfsHandle, newPath string, _ int) error { + h := s.getHandle(handle) + if h == nil { + return syscall.EBADF + } + newPath = cleanPath(newPath) + return convertError(s.vfs.Rename(h.path, newPath)) +} + +// Symlink creates a symbolic link (not supported) +func (s *smbVFS) Symlink(_ vfs.VfsHandle, _ string, _ int) (*vfs.Attributes, error) { + return nil, syscall.ENOSYS +} + +// Link creates a hard link (not supported) +func (s *smbVFS) Link(_ vfs.VfsNode, _ vfs.VfsNode, _ string) (*vfs.Attributes, error) { + return nil, syscall.ENOSYS +} + +// Listxattr lists extended attributes (not supported) +func (s *smbVFS) Listxattr(_ vfs.VfsHandle) ([]string, error) { + return nil, nil +} + +// Getxattr gets an extended attribute (not supported) +func (s *smbVFS) Getxattr(_ vfs.VfsHandle, _ string, _ []byte) (int, error) { + return 0, syscall.ENOENT +} + +// Setxattr sets an extended attribute (not supported) +func (s *smbVFS) Setxattr(_ vfs.VfsHandle, _ string, _ []byte) error { + return syscall.ENOSYS +} + +// Removexattr removes an extended attribute (not supported) +func (s *smbVFS) Removexattr(_ vfs.VfsHandle, _ string) error { + return syscall.ENOSYS +} + +// oShlock and oExlock are lock flags that the SMB server may add to Open flags +const ( + oShlock = 0x10 + oExlock = 0x20 +) + +// Check that smbVFS implements VFSFileSystem +var _ vfs.VFSFileSystem = (*smbVFS)(nil) diff --git a/go.mod b/go.mod index 746141263..67aa73c4d 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 github.com/lanrat/extsort v1.4.2 + github.com/macos-fuse-t/go-smb2 v0.0.0-20260415210427-68ce51f6f833 github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-runewidth v0.0.22 github.com/mholt/archives v0.1.5 diff --git a/go.sum b/go.sum index 5f8afb2bb..f6a1f12eb 100644 --- a/go.sum +++ b/go.sum @@ -493,6 +493,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/macos-fuse-t/go-smb2 v0.0.0-20260415210427-68ce51f6f833 h1:HlhbBNR8LXsXBvPXZHypu90uakAb8ulkAdunABeY9ac= +github.com/macos-fuse-t/go-smb2 v0.0.0-20260415210427-68ce51f6f833/go.mod h1:uuaCnBNqDHH0WcjKjxYbZaO65a9rxUk0k1YyTOGP8gs= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=