smb

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: BSD-3-Clause Imports: 22 Imported by: 0

README

smb

A pure-Go SMB2 server that exports any go-filesystems Filesystem, so a disk image can be mounted by the file manager of Windows, macOS or Linux with nothing installed on the client side.

Sibling of nfs, webdav and sftp, and the one those three cannot replace: SMB is what Windows speaks natively, and what macOS's Finder speaks best.

Why not "cifs"

mount -t cifs is what a Linux user types, and this is the server it connects to — but CIFS names SMB 1, which Windows removes by default and which this server will not speak. The one SMB1 frame it answers is the legacy greeting, and the answer is "let us speak SMB2".

Measured on macOS 26's client: it opens with an SMB1 NEGOTIATE offering NT LM 0.12, SMB 2.002 and SMB 2.???, and once told to use SMB2 it offers dialects 0x0202, 0x0210, 0x0300, 0x0302 and 0x0311 — no CIFS in sight.

Status

A share mounts, and you can work in it.

Verified with the macOS kernel client on macOS 26 — mount_smbfs, then ls, cat, a write, and a 512 KiB copy whose sha256 matches — over a FAT32 image served by go-filesystems/fat32. smbutil statshares reports SMB_3.0.2 and SIGNING_SUPPORTED TRUE.

And with the Linux kernel client in CI, which mounts it with no dialect named:

//127.0.0.1/disk on /mnt type cifs (rw,vers=default,username=alice,…)

And with Windows — the client this was written for. Windows 11 ARM64 25H2, New-SmbMapping, then dir, type, a subdirectory, a write, a rename, a delete, and a 512 KiB copy whose sha256 matches the host's. The client reports what it negotiated:

ServerName ShareName Dialect Signed Encrypted
---------- --------- ------- ------ ---------
10.0.2.100 IPC$      3.0.2     True     False
10.0.2.100 shared    3.0.2     True     False

Signed True is why signing is implemented: Windows requires it. The per-user lists hold there too — a reader's write comes back as "The media is write protected", and a share allow does not name them as "Access is denied". See docs/verifying-with-windows.md for the recipe and the two traps that make this hard to do at all.

dialect negotiation including the 1996 greeting a modern client still opens with
NTLMv2 over SPNEGO the password never leaves the server
opening, reading, writing positional through Opener/WritableFile, whole-file where a driver has neither
listing, renaming, truncating, deleting including the chained requests macOS sends on every open, and the ones Windows sends whose FIRST operation is meant to fail
signing HMAC-SHA256 for 2.x, AES-CMAC for 3.x, both implemented here
dialects 2.1, 3.0 and 3.0.2 — Linux mounts with no vers= at all, macOS settles on 3.0.2
encryption and 3.1.1 not yet — 3.1.1 changes the shape of the exchange, and naming it without pre-authentication integrity would promise what is not there
byte-range locks taken, released and enforced on reads and writes, including waiting for one
change notification on changes that go through this server — one made in the image by something else is invisible, because nothing underneath tells us
asynchronous replies STATUS_PENDING with an AsyncId, and CANCEL
per-user access who may connect (allow) and who may write (writers), per share — a reader is told so in the access mask rather than one refusal at a time
share enumeration NetrShareEnum over DCE/RPC on \srvsvc, listing what this user may connect to — verified against Samba's own client
streams, oplocks and leases not yet, and each answers by name rather than by silence

Enumeration is verified with Samba's smbclient -L, which is the reference implementation of the client side. macOS cannot judge it: smbutil view binds with ncacn_np:HOST[\pipe\srvsvc], and a named-pipe binding has no port field, so it dials 445 whatever the URL said — against a server on a high port it logs RPC to srvsrvc gave error 0x16c9a034, falls back to the SMB1 \PIPE\LANMAN call, and prints "unable to list resources: Broken pipe". A proxy between the two shows the tree connect to IPC$ and then nothing: the pipe is never opened. Serving on 445 needs privilege, so that check is a person's to run.

Windows found one defect nothing else could, and it is the reason to test against an operating system rather than a library: a chained request whose predecessor failed was carried out anyway. Windows checks that a rename's target name is free with a compounded CREATE + CLOSE in one message, and the CREATE is supposed to fail. The CLOSE that follows carries an all-ones file id — "the file the previous operation opened" — and with no such file it resolved to whatever the connection opened last: the source file, still held by the client. The next SET_INFO came back FILE_CLOSED and the rename failed with "The handle is invalid", about a handle the server had shut behind the client's back. macOS and Linux never send a chain whose first operation fails, and the Go client never chains at all.

What is still unchecked: this ran through a QEMU guest-forward rather than on port 445 itself, and encryption and 3.1.1 are absent whatever the client is.

Serving one from the command line

The command lives in its own product now: go-fileshare/fileshare, which serves the same images over SMB, NFS and WebDAV from one configuration — the same users, the same per-share access, in one place.

go install -tags nonfs,nowebdav github.com/go-fileshare/fileshare@latest   # SMB only

fileshare --image disk.img --user alice --password-file pw   # one image, now
fileshare --config /etc/fileshare.d                          # several, with users
fileshare check /etc/fileshare.d                             # before restarting it

cmd/smb-server used to live here and has been removed. Two reasons, and the second is the one that matters:

  • It could never be installed. Its go.mod carried replace github.com/go-filesystems/smb => ../.. so that it always built against this library's HEAD — and go install pkg@latest refuses a module with a replace directive outright. The line in this README telling people to run it was wrong for its whole life.
  • A person wants to share an image, not to run the SMB one. Which protocol carries it is a property of the client at the other end. One command that serves an image over SMB, NFS and WebDAV — with one set of users and one set of access rules — is the thing that was actually wanted, and a per-protocol command is that thing minus two protocols.

Building only SMB into it is a build tag, so the binary is not carrying what you did not ask for.

Serving one from Go

fs, err := fat32.Open("disk.img", -1)
if err != nil {
	return err
}
defer fs.Close()

srv := smb.New()
srv.AddUser("alice", "hunter2")
if err := srv.Share("disk", fs); err != nil {
	return err
}
return srv.ListenAndServe("127.0.0.1:4445")

Port 445 is the one a client dials without being told, and it needs privilege on every operating system — so the examples use a high port, and the mount command names it.

Licence

BSD-3-Clause.

Documentation

Overview

Package smb implements an SMB2 server that exports any github.com/go-filesystems/interface.Filesystem, in pure Go with CGO_ENABLED=0 and no dependency outside the standard library.

It is the sibling of go-filesystems/nfs, /webdav and /sftp, and the one they cannot replace: SMB is what Windows speaks natively — its NFS client is an optional feature and its WebDAV redirector is fragile — and it is what the macOS Finder speaks best.

Why the package is not called cifs

`mount -t cifs` is what a Linux user types, and this is the server it connects to. But CIFS names SMB 1, which Windows removes by default and which this server will not speak. Exactly one SMB1 frame is answered here: the legacy greeting, whose answer is "let us speak SMB2".

That is not a reading of the specification, it is what the client on the desk did. macOS 26 opens with an SMB1 NEGOTIATE offering "NT LM 0.12", "SMB 2.002" and "SMB 2.???"; told to use SMB2, it offers 0x0202, 0x0210, 0x0300, 0x0302 and 0x0311, and nothing older.

What is implemented

Dialects 2.1, 3.0 and 3.0.2; NTLMv2 authentication over SPNEGO or raw, as the client prefers; signing, with HMAC-SHA256 or AES-CMAC as the dialect requires; and the file operations a file manager performs: opening, reading, writing, listing, renaming, truncating and deleting.

That is enough for `mount -t cifs` on Linux -- with no vers= at all -- and for mount_smbfs on macOS, which settles on 3.0.2 signed, and for the Windows redirector, which reports 3.0.2 with Signed True.

Several requests can arrive in ONE message, and the ones after the first may say they are RELATED: they carry no session or tree of their own, and an all-ones file id means "the file the previous operation opened". A related request whose predecessor FAILED is refused with the predecessor's status rather than carried out -- there is no such file, and doing it anyway acts on whatever the connection opened last. Windows checks a rename's target with a compounded CREATE + CLOSE whose CREATE is meant to fail; carrying out that CLOSE shut the client's source handle and the rename came back "The handle is invalid".

3.1.1 is not here. It adds pre-authentication integrity and negotiate contexts, which change the shape of the exchange itself; naming it without them would promise what is not there. Nor is encryption.

Byte-range locks are here, and enforced: a read crosses a shared lock and stops at an exclusive one, a write stops at either, and a handle never conflicts with itself. A client that asks to WAIT for one is promised an answer and gets it when the holder lets go, or STATUS_CANCELLED if it gives up first.

Change notification is here too, on the same machinery, with one limit stated where it will be met: the changes reported are the ones that go THROUGH THIS SERVER. A file written into the image by something else is invisible, because nothing underneath tells us.

Both work because a reply may now be sent later: an interim STATUS_PENDING with an AsyncId, the loop carrying on reading, and the real answer whenever it is ready. CANCEL ends one, and so does closing the handle it was taken on.

A client can also ask what shares there ARE, rather than being told a name: TREE_CONNECT to IPC$, open \srvsvc, and call NetrShareEnum over DCE/RPC. The list is the shares this user may connect to -- see access.go -- and srvsvc.go is the three formats that carry it.

Not here: alternate data streams, security descriptors, oplocks and leases (so a client caches nothing), and the other pipes a client may ask for (\wkssvc, \lsarpc). Each of those answers by name rather than by silence.

Serving one

fs, err := fat32.Open("disk.img", -1)
if err != nil {
	return err
}
defer fs.Close()

srv := smb.New()
srv.AddUser("alice", "hunter2")
if err := srv.Share("disk", fs); err != nil {
	return err
}
return srv.ListenAndServe("127.0.0.1:4445")

Port 445 is the one a client dials without being told, and it needs privilege on every operating system -- so the examples use a high port, and the mount command names it:

mount_smbfs //alice@127.0.0.1:4445/disk /Volumes/disk          # macOS
mount -t cifs //127.0.0.1/disk /mnt -o port=4445,vers=2.1,...   # Linux

Where a password comes from

Server.AddUser takes the password. Server.AddUserHash takes its MD4 -- the "NT hash" -- which is what a directory keeps when it holds enough for SMB without holding the password: Samba's sambaNTPassword attribute, or a column beside it in a database.

It exists because NTLMv2 is a challenge-response. The client never sends the password, so a server must compute MD4(UTF16LE(password)) itself -- which means an LDAP BIND cannot authenticate an SMB session, and neither can a bcrypt. Worth being plain about: the hash IS the credential, and anybody holding it can authenticate as that person exactly as if they held the password.

Who gets what

A share with no lists on it is every authenticated user's, read-write. AllowUsers and WriteUsers narrow that, and compose:

srv.Share("photos", photos, smb.AllowUsers("alice", "bob"), smb.WriteUsers("alice"))

Bob may connect and read; Carol is refused at TREE_CONNECT with ACCESS_DENIED, which a client shows as a permission rather than a missing share. ReadOnly outranks both.

A reader is told so in the access mask of the reply that grants the share, not one refusal at a time: a client that was granted the write bits offers the actions and fails on each, which looks like a broken share.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Server

type Server struct {
	// contains filtered or unexported fields
}

A Server exports one or more shares over SMB2.

fs, err := fat32.Open("disk.img", -1)
…
srv := smb.New()
srv.AddUser("alice", "secret")
if err := srv.Share("disk", fs); err != nil {
	return err
}
return srv.ListenAndServe("127.0.0.1:4445")

Port 445 is the one clients dial without being told; it needs privilege on every OS, so the examples use a high port and the mount command names it.

func New

func New() *Server

New returns a server with no shares and no users. A server with no users authenticates nobody: SMB has no anonymous mode worth offering, and a client asked to mount without credentials is told so rather than let in.

func (*Server) AddUser

func (s *Server) AddUser(user, password string)

AddUser adds a set of credentials the server will accept.

func (*Server) AddUserHash added in v0.2.0

func (s *Server) AddUserHash(user string, ntHash []byte) error

AddUserHash adds a user whose password this server does not have, only its MD4 -- the "NT hash", which is what a directory keeps: Samba's sambaNTPassword attribute, or a column beside it in a database.

It exists because NTLMv2 is a challenge-response. The client never sends the password, so the server must compute MD4(UTF16LE(password)) itself; a site whose people live in LDAP cannot answer that with a bind and cannot answer it with a bcrypt. The hash IS the credential here, which is worth being plain about: anybody holding it can authenticate as that person, exactly as if they held the password. It is not a password hash in the sense a login form means, and storing it does not make a leak less bad.

The hash is 16 bytes. A shorter or longer one is refused rather than padded: a mangled hash would fail every login with "wrong password", which is the least useful thing a server could say.

func (*Server) Close

func (s *Server) Close() error

Close stops the listeners and drops every connection.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(addr string) error

ListenAndServe listens on addr and serves until Close.

func (*Server) Serve

func (s *Server) Serve(ln net.Listener) error

Serve accepts connections until the listener is closed.

func (*Server) SetName

func (s *Server) SetName(name string)

SetName sets the NetBIOS-style name the server calls itself in the NTLM challenge. It is cosmetic -- clients show it -- but it must be stable across the two halves of an authentication, which is why it is a field and not a per-message decision.

func (*Server) Share

func (s *Server) Share(name string, fsys filesystem.Filesystem, opts ...ShareOption) error

Share exports a filesystem under a name. The name is what appears after the host in \\host\name, and SMB compares it without case.

type ShareOption

type ShareOption func(*share)

ShareOption changes how one share is exported.

func AllowUsers

func AllowUsers(users ...string) ShareOption

AllowUsers names the only users who may connect to this share. Called more than once, the names accumulate.

func ReadOnly

func ReadOnly() ShareOption

ReadOnly refuses every write on this share, whatever the driver underneath would have allowed.

func WriteUsers

func WriteUsers(users ...string) ShareOption

WriteUsers names the only users who may write to this share. Everyone else who may connect gets it read-only.

Directories

Path Synopsis
cmd
smb-server module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL