README
¶
fileshare
Share a disk image — or a directory — over SMB, NFS, WebDAV, SFTP and S3 — one configuration, one binary, pure Go.
Documentation: https://go-fileshare.github.io/docs/
go install github.com/go-fileshare/fileshare@latest # with Go 1.27.2 or later
Or a binary, with no Go at all (since v0.29.1): every release carries one static binary per platform (Linux on amd64, arm64, riscv64, ppc64le, s390x and loong64; macOS; Windows; FreeBSD; OpenBSD; NetBSD on amd64 since v0.29.2) on its release page. Check what you downloaded, in either of two ways:
sha256sum -c --ignore-missing SHA256SUMS # the file is the one published
gh attestation verify fileshare_v0.29.1_linux_amd64.tar.gz -R go-fileshare/fileshare
# and GitHub built it, from that tag
The archives are reproducible: hack/release.sh rebuilds them from a checkout
of the tag with the same Go, byte for byte.
Nothing is published that was not run first. Before the files are added to a
release, CI starts each binary on its own system: Linux amd64 and arm64, macOS,
Windows, and NetBSD, FreeBSD and OpenBSD in a virtual machine. It puts each
through the quickstart: a directory over WebDAV, a file put and read back, and
401 for an anonymous client and for a wrong password (hack/smoke.sh).
fileshare --image disk.img --user alice --password-file pw # one image, now
fileshare --config /etc/fileshare.d # several, with users
The password comes from a file, never a flag: an argument is visible in the process list to every user on the machine.
smb on 0.0.0.0:445 — photos and scratch
webdav on 0.0.0.0:8080 — photos and scratch
sftp on 0.0.0.0:2222 — photos and scratch
nfs on 0.0.0.0:2049 — scratch
photos is not served over nfs: it is restricted to alice and bob, and
NFSv3 on its own has no authentication: AUTH_UNIX is a claim the client
makes about itself and the wire cannot disagree with it. A kerberos
block lifts this: sec=krb5 carries a principal a ticket proves; so
does identity = "certificate" on the nfs serve block: RPC-over-TLS
with a client certificate naming the person
That last refusal is the one that used to be permanent. A kerberos block
makes NFS able to tell people apart (so does a client certificate, with
identity = "certificate"), and photos is then served over it like
anywhere else — to alice and bob, and to nobody else:
kerberos {
realm = "EXAMPLE.ORG"
keytab = "/etc/fileshare/krb5.keytab"
}
The realm is compared, not just the name before the @: two realms can each
have an alice, and only one of them is yours.
A person wants to share an image. Which protocol carries it is a property
of the client at the other end: macOS and Windows reach for SMB, a Linux fleet
already has NFS, a browser or a phone has HTTP. Running three servers, each
with its own configuration file and its own idea of who alice is, is a way to
get two of them subtly wrong.
The filesystem inside the image is worked out rather than declared —
go-filesystems/detect reads the
magic and hands back the driver that owns it: fat32, exfat, ext4, ntfs, ufs,
iso9660, squashfs or hfsplus.
That is every driver of the one shape — OpenReader(io.ReaderAt, int64), a
filesystem at offset zero.
apfs, btrfs, xfs and zfs cannot be recognised that way: each opens a disk image and picks a partition, so there is no magic at offset zero to find. A share says which:
share "photos" {
image = "/srv/disk.img"
filesystem = "xfs"
partition = 2 # or leave it out: -1, the first data partition
}
A partition, for any filesystem
A disk image holding FAT32, ext4 or exFAT has a partition table in front of it as often as one holding XFS does — and detection reads offset zero, where a partitioned image has the table. So the partition is chosen first, and the driver is handed a view of it:
share "photos" {
image = "/srv/disk.img"
partition_label = "photos" # what lsblk calls PARTLABEL
}
Three ways to name one, and only one may be given — two that disagree would serve whichever the code tried first:
partition = 2 |
counting from 1, the way partitioning tools print them |
partition_label = "photos" |
the GPT partition name |
partition_uuid = "…" |
the GPT unique GUID — Linux's PARTUUID |
⛔ An index moves. A disk repartitioned, a tool that writes entries in
another order, an image restored with one partition fewer — and partition = 2
names something else, silently, because a filesystem is still found there. A
label or a UUID names the partition itself, which is why fstabs stopped using
indexes years ago. The index is here for MBR images, which have neither.
A share that chose a partition is read-only, and says so at startup: the driver's offsets are the partition's while the file underneath is the whole disk, so a write would land at that offset from the start of the image — on the partition table, as often as not.
⛔ Naming a filesystem turns detection off for that share. That is the point — and it is the risk, so the image is opened as that or refused. A FAT32 image told it is XFS does not become an XFS share; it fails to start, saying what it was asked to open it as.
filesystem is accepted for the sniffable ones too, and then the two are
compared: a share that says ext4 over a FAT32 image is refused with the
share says ext4 and the image holds fat32. That is how a site refuses a
misdetection rather than discovering one later. check marks a named driver
with an asterisk, because that row was not recognised — it was asserted.
What a protocol can promise
The protocols do not agree about the one thing access control needs: whether the server can tell who is asking.
| SMB | NTLMv2. The password never crosses the wire, and the share tells a reader they are one — in the access mask, before they try. |
| WebDAV | HTTP Basic, over whatever TLS the transport gives it. A share a person may not use answers 404, not 403: it is not confirmed to exist. Nor to somebody who has not authenticated: a share and a name that is none get the same challenge, and only then the same 404. |
| SFTP | A public key, or an SSH certificate from an authority you trust: the server never holds the secret, and with a certificate a person's access is issued and expires elsewhere. No password: a client that prompts for one is doing the thing keys exist to avoid. |
| S3 | SigV4, header or presigned. The secret proves itself by computing an HMAC and never crosses the wire — so, like NTLMv2, the directory must HOLD the password rather than merely check it. A share is a bucket. |
| OIDC (over WebDAV) | A bearer token an identity provider signed. Verified by go-authn/oidc: signature, issuer, audience, expiry. No other protocol here has anywhere to put one. |
| NFSv3 | Nothing. AUTH_UNIX is a claim — the client says "uid 501" and the wire cannot disagree. There is no encryption either. |
So a share that names who may use it is not exported over NFS. Not a
warning, not an option: a configuration saying "photos belongs to alice" and a
protocol handing photos to whoever connects cannot both be honoured, and
quietly widening access is the worse of the two failures. The refusal is
printed at startup and in check, with the reason.
⛔ Nothing waits for ever on somebody who has not said who they are. WebDAV and S3 give a request 30 seconds to finish its headers — the TLS handshake included — and close a keep-alive connection idle for two minutes; SFTP gives the SSH handshake and its authentication 30 seconds, and lifts the deadline at login, so a session left idle stays open. Up to v0.16.7 none of the three had a deadline, and a client that sent half a request held its connection for good.
A large upload over WebDAV can go in pieces (since v0.29.0). One PUT is
held in memory whole, up to 1 GiB, so a client that cannot stream (a browser,
the go-fileshare explorer) sends an empty PUT, then each piece with a
Content-Range that starts where the last one ended, then a MOVE to the real
name. A range that starts past the end is still 416: it would leave bytes
nobody sent.
The root lists the shares, to WebDAV clients too (since v0.29.0). A
browser at / gets a page of the shares it may open; a PROPFIND of / gets
them as collections (207), so a file manager can mount the server rather than
one share. Depth infinity is refused (403, propfind-finite-depth), and so is
anything that would write at the root (405). Up to v0.28 every method at /
got the page, with a 200 no WebDAV client could read.
The configuration
name = "ATTIC"
user "alice" { password_file = "/etc/fileshare/alice.pw" }
user "bob" { password_file = "/etc/fileshare/bob.pw" }
group "family" { members = ["alice", "bob"] }
share "photos" {
image = "/srv/photos.img"
allow = ["@family"] # a group, or a person, in either list
writers = ["alice"] # bob gets it read-only
}
share "scratch" {
image = "/srv/scratch.img" # anyone who authenticates, read-write
}
tls {
cert_file = "/etc/fileshare/tls/fullchain.pem"
key_file = "/etc/fileshare/tls/key.pem"
}
serve "smb" { addr = "0.0.0.0:445" }
serve "webdav" {
addr = "0.0.0.0:443"
tls = true
}
serve "sftp" { addr = "0.0.0.0:2222" }
serve "nfs" { addr = "0.0.0.0:2049" }
SFTP: keys, or certificates
Every client already has it — sftp ships with OpenSSH, the Finder and GNOME
mount it, editors speak it — and it is the one protocol here whose shape does
not fit: a person logs in and lands in one filesystem, not a list of
shares. So the shares become the top-level directories of a tree built for
whoever just authenticated:
$ sftp -i ~/.ssh/id_ed25519 -P 2222 alice@attic
sftp> ls
photos scratch
sftp> cd photos
sftp> get holiday.jpg
A share alice may not use is not a directory alice can see, and a share she may only read refuses her writes in the tree, before a driver that would have allowed them. A rename across two shares is refused: it would be a copy and a delete over two images, and that is not what rename promises anywhere.
host_key_file = "/etc/fileshare/ssh_host_ed25519_key"
trusted_user_ca_file = "/etc/fileshare/ca.pub" # optional
user "alice" {
authorized_keys_file = "/etc/fileshare/alice.pub"
}
user "carol" {} # nothing here: her certificate is her credential
With trusted_user_ca_file, a certificate signed by that authority and naming
the user among its principals is enough — so a person's access is issued and
expires elsewhere, and no file here is edited when somebody joins or leaves.
The signature, the validity window and the principals are checked by
x/crypto/ssh's CertChecker; verified against OpenSSH's own client, which
also refuses the same key once its certificate is moved aside.
⛔ An authorized_keys_file may not lie inside a share, and a share holding one
is refused at start: whoever may write there would add a key of their own and
log in as its owner. A /home share and ~/.ssh/authorized_keys files are
exactly that, so keep the keys this server reads outside every share.
Certificates meant for this host: the domain grant
The EuroHPC Federation Platform's SSH CA
(GÉANT / MyAccessID) signs certificates that every site of the federation
trusts, and says in the ssh-domain-grant@core.aai.geant.org extension which
hosts each one is meant for. sshd ignores the extension, and so did this
server: a certificate granted for somebody else's machines was as good here.
With ssh_domains, it is read -- parsed and matched by
go-authn/sshcert, as the specification
says -- and a certificate whose grant names none of this host's names is
refused.
trusted_user_ca_file = "/etc/fileshare/efp-ssh-ca.pub"
ssh_domains = ["files.example.org", "sftp.example.org"]
# ssh_accept_ungranted = true # only if a trusted CA never writes a grant
| key | |
|---|---|
ssh_domains |
this host's names, as a grant names them. A certificate is accepted if one pattern of its grant matches one of them: * is one or more characters within one label, so *.example.org grants files.example.org and not a.files.example.org, files-*.example.org does not grant files-.example.org, and the comparison ignores case. Host names, not patterns; at least one authority (trusted_user_ca_file, or the oidc block's ssh_ca_file) must be trusted. |
ssh_accept_ungranted |
let in a certificate with no grant. Off by default. |
| certificate | ssh_domains |
+ ssh_accept_ungranted |
no ssh_domains |
|---|---|---|---|
granted one of ssh_domains |
in | in | in |
granted other hosts only, or [] |
refused | refused | in |
| no grant | refused | in, logged | in |
a grant that does not parse (null, [1], not compact...) |
refused | refused | in |
Without ssh_domains nothing is read and nothing changes. The grant is read on
the certificates an authority signed: trusted_user_ca_file's, and the
provider's (ssh_ca_file). An OpenPubkey certificate is signed by the user's own
key, so a grant in it would be the user's word about themselves; its audience
is opkssh_client_id. Plain keys carry no grant and are not concerned.
Each refusal is logged with the certificate's serial and key ID and the reason:
sftp: alice: certificate 42 ("alice@efp") refused: its domain grant [login.example.eu] names none of this host's names [files.example.org sftp.example.org]
sftp: alice: certificate 43 ("staff") refused: it has no domain grant (ssh-domain-grant@core.aai.geant.org), and ssh_domains requires one
and counted, by decision, in fileshare_sftp_domain_grant_total{result=...}:
granted, accepted_ungranted, refused_not_granted, refused_absent,
refused_malformed. A client offers a certificate before it proves it holds the
key, so these count attempts, not people.
⛔ Why fail-closed. GÉANT's own ssh-cert-authorize lets a certificate with
no grant through ("might be valid for non-domain-based auth"), and reads a grant
that does not parse as no grant. On a host that trusts a second authority -- its
own CA for staff, a test CA, EFP's staging CA appended to the same file, a
go-authn/bridge client without grants -- that authority's certificates carry no
grant, and the filter filters nothing. So no grant is a refusal unless
ssh_accept_ungranted says otherwise, and a grant that is present and does not
parse is a refusal whatever it says: an authority that wrote one meant to
restrict the certificate. go-authn/sshcert's README walks through the
multi-CA scenario.
With EFP's CA. trusted_user_ca_file is OpenSSH's TrustedUserCAKeys:
put in it the key EFP publishes at https://sshca.my-eurohpc.eu/config
({"PublicKey": "ssh-ed25519 ..."}, the value of PublicKey on a line of its
own), as EFP's trust page
says, and set ssh_domains to the names your hosting entity's grant uses. An
EFP certificate's one principal is the MyAccessID identifier
(<id>@myaccessid.org), so that is the user name it logs in as: a user block
of that name, with no credential of its own, receives it.
With go-authn/bridge. A bridge client with ssh_certificates = true and
ssh_domain_grants = ["files.example.org"] issues certificates in the EFP
profile, granted for this host. Trust bridge's CA either way this server
does: as trusted_user_ca_file (bridge serves its key, EFP's format, at
/ssh/config), for local accounts, or as the oidc block's ssh_ca_file, for
people the provider vouches for. bridge's other clients write no grant; if
their certificates must keep working here, that is what ssh_accept_ungranted
is for -- and it lets in every ungranted certificate of every trusted
authority, so prefer giving those clients a grant.
A certificate pinned to an address is accepted from that address only.
bridge's ssh_source_address writes the source-address critical option, and
it is enforced with ssh_domains as without it: a login from an address the
certificate allows is let in (when its grant also names this host), one from
any other address is refused. This holds for trusted_user_ca_file, for the
oidc block's ssh_ca_file, and for an OpenPubkey certificate pinned with
ssh-keygen -O source-address=...: since v0.22.1, built on
go-filesystems/sftp v0.5.1, its sshd enforces it on every certificate path
(go-filesystems/sftp#19). The tests log in from 127.0.0.1, ::1 and, on Linux,
127.0.0.2, so an address is matched as an address, IPv6 included, and not as
"loopback". Any other critical option
(force-command, verify-required, ...) is refused, since this server does not
act on it.
People the identity provider vouches for, over SFTP
oidc {
issuer = "https://login.example.org"
audience = "fileshare"
ssh_ca_file = "/etc/fileshare/bridge-ca.pub" # go-authn/bridge's ssh_ca
opkssh_client_id = "opkssh" # OpenPubkey logins
opkssh_max_age = "24h" # 12h, 24h, 48h, 1week
}
- A certificate the provider's SSH CA signed --
authn-bridge ssh-certwrites one after a login through the federation. Its principal is the person, itsgroups@go-authn.orgextension their groups, sooidc:groups:rules apply. It is nottrusted_user_ca_file, whose certificates are about local accounts: a local authority's certificate claiming the provider's groups is read as the local account it names. - An OpenPubkey certificate, as
opkssh loginwrites: signed by the user's own key, with an ID token that commits to that key. It is checked asopkssh verifychecks it -- the openpubkey verifier (the provider's signature against its published keys, the nonce commitment, the client ID, the age), then the certificate's key against the token's -- and the SSH user name must be the token's username, because there is no auth_id file here to map one to the other:sftp alice@univ-example.fr@files.example.org.
⛔ A certificate is checked at login, and revoking the person does not
revoke it. Disabling somebody in go-authn/bridge revokes their tokens and
deletes their application passwords -- which a directory reload applies here,
closing their SMB, WebDAV and S3 sessions -- but a certificate already issued
still opens SFTP until it expires, and an SFTP session already open stays
open: these people are not in the directory, so no reload concerns them. The
window is the certificate's lifetime: bridge's ssh_ca { validity } (12h by
default, at most 168h, and never past the IdP session's end) and
opkssh_max_age here. Keep it as short as the clients' re-login allows --
authn-bridge token and opkssh fetch a new one without asking the person.
Revoking a certificate
oidc {
# ...
ssh_ca_file = "/etc/fileshare/bridge-ca.pub"
ssh_krl_url = "https://bridge.example.org/ssh/krl" # or ssh_krl_file
ssh_krl_state_file = "/var/lib/fileshare/ssh.krl.state" # the order, across a restart
ssh_krl_max_age = "1h" # default; ssh_krl_refresh = "1m"
}
go-authn/bridge publishes the SSH certificates it has revoked — a person
disabled, an IdP disabled — as an OpenSSH KRL (PROTOCOL.krl, the list
sshd's RevokedKeys reads), and fileshare keeps a copy of it, read with
go-authn/krl. A revoked certificate is
refused at login, and a session it already opened stops being served: SSH
checks a certificate once, so every operation of a federated SFTP session asks
the KRL again, open files included.
⛔ It fails closed. While the KRL cannot be fetched, or its last good copy is
older than ssh_krl_max_age, the provider's certificates are refused: a
revocation check that lets everything through when the list is unreachable is
the one an attacker who can block the list defeats. The list must then be
served as reliably as the logins it governs; fileshare_revocation_list_age_seconds
is the metric to alert on before max_age is reached.
⛔ A KRL from ssh_krl_url must be signed and say when it expires, as
go-authn/revocation describes and
go-authn/bridge serves from v0.10.0:
- The signature:
<url>.sigis a detached SSHSIG signature by the provider's SSH CA (ssh_ca_file, whichssh_krl_urltherefore requires), in the namespacekrl@go-authn.github.io. It is fetched withIf-Matchon the list's ETag, and fetched again at once if the list was re-issued in between. - The expiry: the
expires@go-authn.github.ioextension. Past it, the list is not current, however recently a304confirmed it.
HTTPS (ssh_krl_ca_file pins its authorities) authenticates the server that
answered, not the list: a mirror, a cache or a compromised web server could
serve an empty one, and nothing in a KRL alone says it is stale. A list that
does not verify is refused, and the last good copy is kept. A list placed by
hand with ssh_krl_file is trusted as the filesystem that holds it, and needs
neither signature nor expiry; one it carries is honoured.
Never backwards, across a restart too. A list is refused if it is older
than the one held: a lower version (CRL number), or the same version issued
earlier. With ssh_krl_state_file (crl_state_file for NFS), the last list
that verified is kept on disk, verified again at start, and orders the next
one. On its own it does not count as current until a list is fetched.
Without it, a restart forgets the order, and fileshare says so at start: an
older list, still signed and unexpired, would be taken. These files, like
ssh_ca_file, the CA files and the list files, may not lie inside a share,
since whoever writes there would decide who gets in. For a plain sshd
next to fileshare, go-authn/revocation's authn-revokd does the same and writes
RevokedKeys.
OpenPubkey (opkssh) certificates are not in any KRL — nothing issued them but
the person's own key — and are bounded by opkssh_max_age: a login, and the
session it opens, end when the PK Token's iat is older than that, or at the
certificate's own end if sooner. The certificate's validity alone bounds
nothing, since the person signs it themselves and may write "forever"; up to
v0.16.7 an open session outlived opkssh_max_age that way.
Revoking what no list covers: shared signals
ssf {
transmitter = "https://bridge.example.org" # its issuer
audience = "https://files.example.org" # what this server is to it
client_id = "fileshare" # OAuth client credentials,
client_secret_file = "/etc/fileshare/ssf.secret" # scope "ssf" (or token_file)
state_file = "/var/lib/fileshare/revocations.json"
max_age = "10m" # default; retain = "192h"
}
A certificate has a revocation list; an access token fileshare verifies on its
own, and an OpenPubkey certificate the person's own key signed, do not — they
are valid until they expire. The OpenID Shared Signals
Framework
with a CAEP
session-revoked event closes that: go-authn/bridge sends one when a person or
an IdP is disabled, fileshare polls for it (RFC 8936, the SSF default) and keeps,
per person, when it happened. Everything the provider issued them before is
refused — a token over WebDAV (its iat), a provider or OpenPubkey
certificate over SFTP (its validity start, the ID token's iat), a client
certificate over NFS (its NotBefore) — and the sessions those opened stop
being served, for good: an SFTP session found revoked stays revoked after the
revocation is forgotten (retain), and its connection is closed. (Up to
v0.16.7 it was only refused, and served again once the revocation was pruned.)
A list that is merely not known to be current refuses without ending the
session, which resumes when the transmitter is heard from again. What the
provider issues after is theirs: a person re-enabled is not locked out.
The subject is RFC 9493's account (acct:user@domain, the name the shares use),
iss_sub, email, or aliases of them. An IdP disabled whole arrives as a
CAEP tenant subject with the IdP's scopes in the event: everyone whose name is
@ one of them — compared whole, evil-univ-a.fr is not univ-a.fr — and
whose credential was issued before, is refused, including people the provider
no longer remembers. The receiver authenticates to the transmitter with OAuth
client credentials (RFC 6749 §4.4, scope ssf), the token fetched from the
transmitter's own token endpoint and renewed before it expires; token_file
is there for transmitters that hand out a long-lived one. A revocation is written down before it
is acknowledged, so it survives a restart, and kept for retain — longer than
any credential it could void. The transport is
github.com/hstern/go-ssf; what an event means
is fileshare's. ⛔ It fails closed, like the KRL: while the transmitter has not
answered a poll within max_age, federated credentials are refused —
fileshare_ssf_last_heard_seconds is the metric to alert on. A poll counts as
heard only when every event it brought was verified and written down: an event
signed by a key not yet published, or a state file that will not take a write,
fails the round, so the event stays queued at the transmitter rather than being
reported and dropped. Every endpoint the transmitter names is reached over
https, a stream it reports as paused or disabled stops the session, and the
keys used are its public, asymmetric ones only. max_age is a minute or more;
retain is at least 169 hours, longer than any credential a revocation voids.
A session's certificate is also checked against its own end, since an expired
certificate leaves the provider's revocation list.
The name. It is preferred_username, or username_claim. Without
preferred_username, it is the email only if the provider says
email_verified: true, and otherwise sub. With username_claim = "email", a
token or PK Token whose email is not verified is refused. Until it is
verified, the email is only what the person typed, and a signed token that
carried it would bind as an address nobody checked (OpenID Connect Core 5.1;
go-authn/oidc v0.2.0).
Which institutions, and which groups
oidc {
# ...
domains = ["univ-a.fr", "univ-b.fr"] # nobody else from the federation gets in
}
share "projet-x" {
image = "/srv/projet-x.img"
allow = ["oidc:groups:urn:mace:univ-a.fr:projet-x", "oidc:domain:univ-b.fr"]
writers = ["oidc:groups:urn:mace:univ-a.fr:projet-x"]
}
domains is checked at authentication, over SFTP and WebDAV alike: a name
must be <something>@<one of them>, compared whole (evilunivb.fr is not
univb.fr). oidc:domain: is the same test for one share. The domain can be
trusted as far as the provider: go-authn/bridge drops an eppn or subject-id
whose scope the IdP's federation metadata does not grant it.
⛔ That holds for the names it scope-checks. A bridge that takes the name
from uid or mail (claims { username = ... }) over a federation of several
IdPs lets one IdP mint alice@univ-a.fr, and domains then lets it through:
take the name from eppn or subject-id, or run go-authn/bridge v0.9.0 or later,
which refuses that configuration unless a single IdP is listed.
Groups are what the institution's IdP releases, turned into the groups
claim by go-authn/bridge's claims { groups = [...] }: eduPersonEntitlement
by default (a lab's or a VO's groups, as eduTEAMS or an institution's group
manager publishes them), or eduPersonScopedAffiliation (staff@univ-a.fr,
student@univ-b.fr). They travel in the token over WebDAV and in the
certificate's groups@go-authn.org extension over SFTP. To see them:
ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub # the Extensions section
and this server says, at every federated SFTP login,
sftp: alice@univ-a.fr, vouched for by the provider, in groups [...].
⛔ Somebody the provider vouches for is still a stranger here unless a rule
names them or trust_all says the provider is the directory -- the same test a
token passes over WebDAV. A provider certificate with no principal, valid for
anybody by the format's own definition, is refused.
Without host_key_file a fresh identity is generated at every start, and the
server says so: every client that has seen it before will warn about a changed
key, which is the client doing its job.
A directory of small files is one configuration: --config /etc/fileshare.d
merges them, so a user in one file and a share in another belong together. A
name in allow or writers that belongs to no user block is refused at
startup — allow = ["alise"] would otherwise lock Alice out of her own share
and start happily. A serve block with no addr lands on the registered port
for that protocol, on loopback.
A token, over the one protocol that can carry one
oidc {
issuer = "https://login.example.org"
audience = "fileshare"
}
A browser has a token and no password. So WebDAV accepts Authorization: Bearer, and the challenge it sends offers both — a client picks the one it
can answer.
⛔ A bearer token: only WebDAV. SMB authenticates with NTLMv2, SFTP with a key or a certificate, NFS with nothing at all, and S3 with a SigV4 signature — an HMAC computed over the request, with no field a bearer token fits in. None of them has anywhere to put an Authorization header. That is a fact about the protocols, not a limit of this program.
The provider's word over SFTP arrives in a certificate instead, in two shapes (see below): one its SSH CA signed, or an opkssh one. A configuration naming a provider and serving neither WebDAV nor such an SFTP is refused rather than started.
The usual way to reach S3 with an OIDC token is STS
AssumeRoleWithWebIdentity, which exchanges the token for temporary
credentials the client then signs with. That is a different thing from
accepting a bearer token, and it is not implemented here — said plainly
because it is the first thing somebody arriving from MinIO will look for.
⛔ A token says who the provider thinks somebody is. It does not say this server has a share for them. A valid token for a name no source here knows is refused — the safe reading of I do not know you is not you are allowed. A site where the provider is the directory says so:
oidc {
issuer = "https://login.example.org"
audience = "fileshare"
trust_all = true # everybody that provider vouches for, not just these people
}
People the provider names, not this file
A federation -- RENATER through go-authn/bridge, say -- knows who is in a project, and a share can ask IT rather than copy the list:
share "photos" {
image = "/srv/photos.img"
allow = ["oidc:groups:urn:mace:univ-example.fr:photos", "oidc:user:bob@univ-example.fr", "alice"]
writers = ["oidc:groups:urn:mace:univ-example.fr:photos"]
}
oidc:groups:<value> is somebody whose token's groups claim holds the value;
oidc:user:<name> is somebody the token names. The spelling is opkssh's, so
that one vocabulary says who reaches a shell and who reaches a share. Somebody
a rule names is known to this server as far as tokens go -- the open shares
too -- and somebody no rule names is still a stranger.
⛔ A rule is about the provider's people only. oidc:user:bob is the bob
the provider vouches for; a local account called bob, with a password, is
somebody else and matches no rule. A rule with no oidc block, a malformed
one, or one that may write without being allowed to connect is refused at
startup.
⛔ Nor is the provider's alice the local alice, unless the configuration
says so. A plain name in allow or writers is a local account; a token or
a provider certificate is reached only by a share's oidc: rules. A site whose
provider's names ARE its local names says it:
oidc {
# ...
local_names = true # the provider's "alice" is this file's alice
domains = ["univ-a.fr"]
}
Only then does the provider's alice get the shares written for the local
alice, write included — so set domains with it, or take the name from a
claim the provider controls (username_claim): a provider where people choose
their own preferred_username would otherwise hand anybody who signs up a
local account's shares. Before v0.20.0 that was the default (security audit
F4). A federated name that is also a local account's, refused for want of
local_names, is logged with the line to add, and fileshare check says which
rule is in force.
An NFS client certificate (identity = "certificate") is the exception: it is
signed by the CA its serve block's client_ca_file pins, chosen for exactly
that, and plain names are the only way an NFS share can name somebody — so its
names are local names, whatever local_names says.
There is no login flow here: no redirect, no client secret, no cookies. This is the resource server.
Where the people come from
A user block is the whole directory for a household. A site whose people are
already in a database or in LDAP should not copy them into a second place that
goes stale, so a users block reads them where they are:
users "sql" {
driver = "postgres" # or sqlite, or mysql
dsn_file = "/etc/fileshare/dsn" # a DSN holds a password: it lives in a file
users = "select login, nt_hash, ssh_keys from staff"
groups = "select team, member from team_members"
}
users "ldap" {
url = "ldaps://ldap.example.org"
base_dn = "ou=people,dc=example,dc=org"
bind_dn = "cn=reader,dc=example,dc=org"
bind_password_file = "/etc/fileshare/bind.pw"
}
The queries are yours, because a site's people are already in that site's
shape; a schema invented here would mean copying them into a second one. The
LDAP side reads what a Samba-aware directory already publishes —
sambaNTPassword, sshPublicKey, memberUid — and the filters and the
user, group and member attributes are configurable; sambaNTPassword is
read under that name only. By default (go-authn/directory v0.14.0) people are
(|(objectClass=posixAccount)(objectClass=account)(objectClass=uidObject))
and groups (|(objectClass=posixGroup)(objectClass=groupOfNames)), whose
member DNs count only when they are the DN of somebody the people search
returned. That is what go-authn/authnd v0.11.0 publishes; an older default
read posixAccount and posixGroup alone.
⛔ The dsn_file, the bind_password_file and — for sqlite — the database
file the DSN names (with its -journal, -wal and -shm files) may not lie
inside a share: whoever may write there would read the directory's
credentials, rewrite them, or point the server at a directory of their own. A
share holding one is refused at start. The LDAP client verifies the server
against the system's trust store; there is no CA file of its own to protect.
Sources are asked in the order they are written, and the first one that
knows a name owns it. The user and group blocks come first, so a service
account written down locally is not overridden by somebody with the same name
in LDAP. Groups are the exception: a group's members are the union of every
source, because a team can have people in a file and in a database.
A group is written @name wherever a person could be:
share "photos" {
allow = ["@engineers", "alice"]
writers = ["@owners"]
}
Expansion happens once, at startup. A membership that changes in LDAP is picked up by a restart — said plainly, because asking the directory on every connection is a different design and this is not it.
What a source can prove, and what each protocol needs
This is the part a site discovers otherwise at a mount, so check says it
first:
| the source has | SMB | S3 | WebDAV | SFTP |
|---|---|---|---|---|
| a password (file, or a cleartext column) | yes | yes | yes | — |
an NT hash (sambaNTPassword, nt_hash) |
yes | no | — | — |
| only a bind, or a bcrypt column | no | no | yes | — |
| public keys, or a trusted CA | — | — | — | yes |
NTLMv2 needs the password or its MD4, and nothing else will do. A client never sends a password to an SMB server — it sends a proof computed from it — so a directory that only checks passwords cannot answer SMB, however good the check is. That is a property of the protocol, not a limitation of this program, and no amount of configuration changes it. WebDAV asks only "is this the right password", which a bind answers.
A person a directory names but proves nothing for is legitimate — a listing
with the secrets elsewhere — and check says so in one line rather than
leaving them to find out.
check, before you restart something people are using
$ fileshare check /etc/fileshare.d
ATTIC
SHARE IMAGE FILESYSTEM WHO MAY CONNECT WHO MAY WRITE SMB WEBDAV NFS
photos /srv/photos.img fat32 alice and bob alice yes yes NO
scratch /srv/scratch.img ext4 anyone who authenticates anyone … yes yes yes
photos is not served over nfs: it is restricted to alice and bob, and NFSv3 has
no authentication at all: AUTH_UNIX is a claim the client makes about itself and
the wire cannot disagree with it
USER FROM AUTHENTICATES WITH SMB WEBDAV SFTP
alice the configuration file a password from /etc/…/alice.pw yes yes yes
bob the configuration file a password from /etc/…/bob.pw yes yes -
dora ldaps://ldap.example.org a password check and an NT hash yes yes -
eli ldaps://ldap.example.org a password check - yes -
this configuration can be served
Every image is opened read-only and closed again, so this is safe to run
against a live server's images. It prints where a credential comes from and
never what is in it — and the per-person columns are why
Identity.Can exists: eli is in the
same group as dora and SMB still cannot serve him, because LDAP holds his
password and gives it to nobody.
Building only what you want
Each protocol is behind a build tag, and a tag leaves it out entirely: no listener, no parser, no dependency, no code.
go install -tags nonfs,nowebdav,nosftp github.com/go-fileshare/fileshare@latest # SMB only
go build -tags nosmb,nonfs,nosftp . # WebDAV only
Where the people come from is behind tags of its own: nosql leaves out
the three database drivers, noldap the LDAP client.
| build | size |
|---|---|
| everything | 31.2 MB |
-tags noldap |
30.9 MB |
-tags nosftp |
30.6 MB |
-tags noopenpubkey (no opkssh logins over SFTP) |
about 2 MB less |
-tags nos3 |
31.1 MB |
-tags nonfs,nowebdav,nosftp,nos3 (SMB only) |
28.1 MB |
-tags nopartitioned (no apfs, btrfs, xfs, zfs) |
29.2 MB |
-tags nosql |
20.2 MB |
-tags nosql,noldap |
19.8 MB |
-tags nosql,noldap,nonfs,nowebdav,nosftp,nos3 |
16.2 MB |
The admin API brought gRPC and protobuf in, and they are behind nogrpc
(linux/amd64, measured 2026-09-29, when the rows above had grown to 34.1 MB for
everything):
| build | size |
|---|---|
| everything, with the admin API | 46.1 MB |
-tags nogrpc |
34.4 MB |
-tags nosql,noldap,nogrpc |
22.5 MB |
-tags noprovisioner leaves out the provisioner role and go-fsctl's zfs,
btrfs and projquota with it; nogrpc leaves it out too.
gRPC costs 11.7 MB — the measurement below, taken again, now that it is
here for a reason of its own. A configuration with an admin block, in a
nogrpc build, is refused rather than served without one.
nosql is by a distance the biggest lever: PostgreSQL, MySQL and SQLite
together weigh 11.7 MB, more than every protocol in this program put
together. A site whose users are in the file wants it. The drivers are imported
by this command and not by the library that uses them, which is what makes the
choice a build tag rather than a fork.
A configuration naming a protocol this binary was built without is told that, rather than "there is no such protocol" — the difference between a typo and a build tag.
Why not subprocess plugins. It was measured rather than argued:
hashicorp/go-plugin brings gRPC and protobuf, which cost 13.2 MB on their
own — more than this entire binary with all three protocols and every driver
in it. A plugin host would be twice the size before loading anything, and each
plugin binary would carry gRPC again.
For attack surface, a tag is also the stronger tool for anything you do not run: code that was never compiled cannot be reached, sandboxed or not. What tags do not give is isolation between the protocols you do run — today they share an address space and the same open images — nor a way to add a protocol without recompiling. Both are real, and both are arguments for a process boundary rather than for a smaller binary — and the next step for them is one process per protocol using this same binary, not a plugin framework. docs/plugins.md has the measurements and the design question that decides it: who owns the image.
One process per protocol
fileshare --config /etc/fileshare.d --isolate
smb on 0.0.0.0:445 — process 19810, serving public, photos and scratch
webdav on 0.0.0.0:8080 — process 19811, serving public and photos
nfs on 0.0.0.0:2049 — process 19812, serving public
The parent binds the listeners and then execs itself once per protocol,
handing each child only the shares that protocol may serve. Checked with
lsof, not by reading the code:
smb (pid 19810) has open: scratch.img photos.img
webdav (pid 19811) has open: photos.img
nfs (pid 19812) has open: photos.img
The writable image is open in exactly one process, and the WebDAV child never opens it at all. That is what build tags cannot give: a panic or an exhausted heap in one protocol takes down one protocol, each child can be confined by whatever the operating system offers, and a bug in one parser cannot reach an image that process never opened.
On Unix the parent passes the bound listener to the child, so a privileged
port works with unprivileged children — the parent binds 445, the child
never needs the privilege. Windows has no ExtraFiles, so there the child
binds the address itself; the isolation is the same, the privileged-port
half is not.
The rule that makes it honest. A child opens the image itself, so an image served writable by two protocols would be two drivers over one file with no lock between them — exactly what the shared lock below prevents inside one process. That is refused, and the refusal says how to fix it:
scratch is writable over smb and webdav, and one process per protocol means
that many drivers writing one file with no lock between them. Say
`protocols = ["smb"]` on the share, or make it read_only, or do not isolate.
protocols = [...] on a share says which of them carry it — useful on its own,
not only under --isolate: a share declared SMB-only is a share the WebDAV
process is never told about. A serve block that would end up carrying nothing
is refused too, before any image is opened, naming the shares that were kept
from it and why.
A device, not only an image
share "photos" {
image = "/dev/sda" # a device node, not a file
}
No privilege is involved. Nothing here mounts anything -- the
go-filesystems drivers read ext4, xfs and
the rest in user space -- so there is no mount(2) and therefore no
CAP_SYS_ADMIN. Opening a device is an ordinary open(2), governed by the
permissions on the node:
| owner | mode | enough | |
|---|---|---|---|
Linux /dev/sda |
root:disk |
0660 | membership of disk |
macOS /dev/disk0 |
root:operator |
0640 | membership of operator, plus Full Disk Access |
A refusal says which group, rather than permission denied -- the answer is
never sudo. Port 445 is the one privileged act left, and --isolate already
handles it: the parent binds the listener, the child that speaks SMB never
needs the privilege.
⛔ A device share is read-only. The same reasoning as a share that chose a partition. Writing to a live disk is not a decision to make on your behalf.
⛔⛔ The device is opened exclusively, and that is about correctness, not
caution. If the kernel has a filesystem mounted from it, the filesystem's
page cache holds newer metadata than the device does -- so a raw reader sees a
directory block from before an update beside an inode block from after it, a
state that never existed on disk at any one moment. O_EXCL on a block device
is the kernel's own primitive for "nobody else, a mount included"; it is what
mkfs and fsck use. A device in use is refused, by name, with the reason.
Two things were measured rather than assumed, and both were surprises:
Stat().Size()is 0 for a device, so the length is asked of the device itself. Seeking to the end answers on Linux and returns 0 with no error on macOS, which is why Darwin usesDKIOCGETBLOCKCOUNTinstead.- A raw node (
/dev/rdiskN, orO_DIRECTon Linux) refuses an unaligned read, and reading a two-byte field at offset 11 is what parsing a FAT BPB is. Reads to a device are rounded out to whole blocks; without that,/dev/rdisk4reportedunknown filesystem-- theEINVALswallowed by a detector that found no magic number.
A directory, not only an image
A share may serve a directory of the host — a container's volume, say — instead of an image:
share "photos" {
directory = "/data/photos"
allow = ["@family"]
}
It is go-filesystems/osfs, which
reaches the tree through an os.Root: .. that climbs out, and a symbolic link
leading out, are refused by the kernel-backed root, not by a string test. A
client may create a link to /etc; nothing will follow it. A FIFO planted in
the tree is refused rather than left to hang the server.
A directory is not put behind the one lock images share (see below): an
image driver owns one file and promises nothing about two calls at once, while
a host tree is the kernel's. filesystem and partition are for an image, and
a directory share naming one is refused.
⛔ A share may not lie inside another share, nor be another share's source. A directory share holding an image or a directory that is a share of its own would be a second way in, with the outer share's rules: the people the inner share refuses — and every protocol that refuses it, NFS refusing a restricted share included — would read and write it as files of the outer one. So a share whose source (links followed) is another share's source, lies inside another share's directory, or is a directory holding one, is refused at start and by the admin API, whichever is written first. Two shares choosing different partitions of one disk image are the one overlap allowed: each driver is confined to its partition, and such a share is read-only.
A directory, served as each person
Ordinarily one fileshare process serves everybody, as one account: fileshare decides who may do what, and every file it creates belongs to that account. A directory share can instead be served as each person. The files of whoever logged in are opened by a process running with their uid, gid and groups. The kernel then applies the tree's permissions, and what they create belongs to them.
identity {
service_user = "fileshare" # the account the protocols are served as
min_uid = 1000 # the default; uid 0 is refused whatever this says
min_gid = 1000
deny_uids = [65534]
idle = "2m" # how long an unused worker is kept
}
share "home" {
directory = "/srv/home"
identity = "system"
}
fileshare serve must then be started as root. It stays root only to
start each person's worker, and runs everything that faces the network in a
second process, as service_user:
- The launcher (
fileshare serve, root) binds the listeners, starts the front and answers the front's requests for workers. It speaks no protocol. - The front (
service_user, no privilege) speaks the protocols and authenticates, asservealways has. For a share served as its people, it names a person and a share to the launcher, and never a uid or a path. - A worker (the person) is started by the launcher for one person and one
share. It checks that it is running as exactly that uid, gid and set of
groups, and that it cannot become root again, before it opens the tree
through an
os.Root. Then it serves the tree to the front over a socket the launcher handed across. One worker serves all of that person's connections to the share, and it stops once nothing has used it foridle.
The launcher resolves the person itself: first the directory's
uidNumber and gidNumber, then /etc/passwd and /etc/group. The
directory has them when it is told to carry them: a user block's
attributes = { uidNumber = "2001", gidNumber = "2001" }, an LDAP source's
attributes = ["uidNumber", "gidNumber"] (and group_attributes = ["gidNumber"] for the supplementary groups), or an SQL source's
users_attributes and groups_attributes queries. It refuses uid 0, gid 0, (uid_t)-1,
anything below min_uid or min_gid, and deny_uids. A person it cannot
resolve is refused: no shared fallback account exists. So a front that has
been taken over can ask only for a person who exists and is allowed, on a
share that exists. It still decides who authenticated, and the launcher
cannot check that for it.
⛔ What this does not do yet:
- SFTP and WebDAV only. SMB, NFS and S3 refuse such a share, and
checksays so. SMB and NFS need a filesystem per session in their libraries; NFS will only ever do it under Kerberos, sinceAUTH_UNIXis the client's own claim. - Unix only: Linux, the BSDs and macOS.
- The front reads the configuration. The configuration file, and every
file it names (password files, host key, certificates), must be readable by
service_user. The binary must be executable by the people, who run it as their workers. - Restart to add or change such a share. The launcher reads the configuration once. A share the front learns of later is refused by the launcher, never served as the front.
--isolatedoes not go with it.
The design, and why each of these choices was made, is in docs/identity.md.
The admin API
admin {
listen = "unix:///run/fileshare/admin.sock" # made 0600
state_file = "/var/lib/fileshare/shares.json"
source_roots = ["/srv/images", "/data"]
}
A gRPC service, fileshare.admin.v1.AdminService:
create, update and delete shares, disable and enable them, grant a
user, a @group, an oidc:groups: value or an oidc:user: name read or write
access, and revoke it; list the shares, the users (with the protocols their
credentials can answer) and the groups, and reload the directory now. Every
change answers with what serving
it did — the generation now served and how many connections were closed.
grpc.health.v1 answers on the same listener.
-
Disabling is Samba's
available = no: the share stays defined and every attempt to connect fails; its open connections are closed and its image or directory is let go of, so the file can be replaced while it is offline. Unlike every other change it applies to a share of the configuration too — taking a share offline is an operation, not a definition — and it survives a restart;fileshare checklists what is offline. -
Over TCP it is mutual TLS or nothing:
tls_cert_file,tls_key_fileandclient_ca_filetogether, loopback included — any local user can reach loopback, and this API decides who reads whose files. The listener is grpc-transports/control. Each change is logged with who made it: the client certificate's CN, or the socket peer's uid. A refusal is logged too, and anything a caller sent that could break the line is quoted. -
allowed_uids, on a unix socket, are the only uids whose calls are answered — read from the kernel (SO_PEERCRED,LOCAL_PEERCRED) at the connection; any other isPERMISSION_DENIEDbefore its request is decoded, the health service included, and the refusal is audited. ⛔ The socket's 0600 mode stays the floor: only its owner and root can connect at all, so the list narrows that (allowed_uids = [990]refuses a process running as root) and can never widen it. Linux and macOS only. -
It manages the shares it created. A share written in the configuration is listed and cannot be changed through the API: a share defined in two places is a question nobody wants to answer. The API's shares live in
state_file, written atomically, and are served again at the next start. -
A source must lie under
source_roots, resolved — links followed,..taken out — and it is the resolved path that is kept. Withoutsource_rootsthe API cannot create shares: the process can read/devand/etc, and nobody meant to hand those to a caller. It is checked again at every start, and a share instate_filethat is no longer under a root stops the start, naming it. The share is opened through its root (os.Root), so a component swapped for a link out of the root later is refused by the kernel. -
Names a protocol can carry. A share name is at most 80 characters, does not begin or end with a space, is not
.or.., and holds no unprintable character and none of:*?"<>|{}%. Subjects hold no unprintable character either, and one share takes at most 1000 grants — a group's job long before. -
No share may contain the configuration, the state file, or the secrets and trust anchors they name — keys and certificates,
password_file,authorized_keys_file, ausersblock'sdsn_file,bind_password_fileand sqlite database, the shared signals'ca_file, token and state, the CA and revocation-list files: whoever writes into it would rewrite who may do what. -
No share may contain another share, nor be another share's source — a configured one or one of the API's, served or offline (see A directory, not only an image).
-
Every share has at least one grant. A share with none is open to anyone who authenticates; the file may say that on purpose, an API call should not say it by omission. So
CreateShareneeds a grant and revoking the last one is refused. -
A change is checked like a configuration, opened, written down, then served — and a change the server cannot honour (an image that will not open, a name nobody in the directory has) is refused with what was served and what was written left as they were.
-
⛔ A change restarts the protocol servers, and closes their connections. SMB checks who may connect once per tree connect and SFTP builds a person's tree once per login, so a session that outlived a revocation would keep the access just taken away. The ports stay bound, so a client connecting during a change waits instead of being refused; images whose share did not change keep their driver, never opened a second time.
The Go code under proto/ is generated and committed, so go install needs no
protoc; after changing the .proto, regenerate it with the versions CI pins
(protoc 34.1, protoc-gen-go v1.36.12, protoc-gen-go-grpc v1.6.2,
protoc-gen-connect-go v2.0.0):
protoc -I proto --go_out=. --go_opt=module=github.com/go-fileshare/fileshare \
--go-grpc_out=. --go-grpc_opt=module=github.com/go-fileshare/fileshare \
proto/fileshare/admin/v1/admin.proto proto/fileshare/provision/v1/provision.proto
protoc -I proto --connect-go_out=. --connect-go_opt=module=github.com/go-fileshare/fileshare \
proto/fileshare/admin/v1/admin.proto
The admin API over HTTPS, for OIDC tokens
The same API, the same state and audit, is also served over HTTPS when the
admin block has a web block (since v0.28.0): Connect, gRPC-Web and gRPC on
one listener, with the tls block's certificate. It is what the UIs talk to
(see docs/ui.md).
admin {
listen = "unix:///run/fileshare/admin.sock"
state_file = "/var/lib/fileshare/shares.json"
web {
listen = "0.0.0.0:8443"
issuer "https://login.example.org" {
audience = "fileshare-a" # what THIS server is called there
groups = ["fileshare-admins"]
}
issuer "https://idp.partner.example" {
audience = "fileshare-a.partner"
subjects = ["5b0c…"] # a person is (issuer, sub)
}
}
}
A call is answered when one issuer's verifier accepts its bearer token --
signature, iss, an aud naming this server, times -- and that issuer's
block names the token's subject or one of its groups. A subject listed at one
issuer is nobody at another. Each server has its own audience: a token
addressed to several could be replayed by any of them to the others
(RFC 8707, RFC 9068). The check runs on the headers alone, before a byte of the
message is decoded. A refused caller is told "refused" and nothing more; the
reason, and every change made, goes to the audit output under
oidc=<issuer> <sub>. There is no CORS: browsers reach it through the UIs'
server, not directly. Requests are bounded to 1 MiB and every phase of a
connection has a timeout.
--isolate does not go with an admin block yet: there is no one process a
change could be applied to.
The provisioner: storage fileshare creates
fileshare provisioner is the binary's second role: the privileged half that
creates the storage a volume share will be served from — a ZFS dataset, a
btrfs subvolume, an XFS or ext4 directory under a project quota — sized,
owned and tracked. Creating storage needs CAP_SYS_ADMIN; fileshare serve
parses five network protocols for strangers and holds no privilege, so the
part that does is a separate process with a small, closed protocol,
fileshare.provision.v1.ProvisionService.
Linux only. The admin API creates volumes through it and serves shares from
them: see Volumes, and the design, docs/volumes.md.
provisioner {
listen = "unix:///run/fileshare-provisioner/provisioner.sock"
client_uid = 990 # fileshare's, and never 0
group = "fileshare" # every volume root is root:fileshare 2770
max_volume = "10T"
state_file = "/var/lib/fileshare-provisioner/volumes.json"
parent "tank" {
zfs = "tank/fileshare"
root = "/srv/fileshare/volumes/tank"
}
parent "fast" { btrfs = "/srv/fileshare/volumes/fast" }
parent "plain" {
xfs = "/srv/fileshare/volumes/plain"
project_ids = "100000-199999"
}
}
- One uid, read from the kernel. The socket's peer is identified with
SO_PEERCREDat the handshake (grpc-go'scredentials/localdoes not report it), and anybody butclient_uid— root included — isPERMISSION_DENIEDbefore a byte of the request is decoded. The socket is 0660root:<group>, in a directory the provisioner owns and nobody else may write. - A closed set of verbs. A method outside the service, or a message that does not decode or carries a field this version does not define, gets no answer: the connection is closed.
- A request never carries a path. It names a
parentfrom this file and a name matching^[a-z0-9][a-z0-9_-]{0,62}$; the volume is<parent root>/<name>. A quota of 0 is refused, one abovemax_volumeisOUT_OF_RANGE, one the parent cannot holdRESOURCE_EXHAUSTED. - Idempotent by name, as CSI is. The same create twice is OK; the same
name with another quota is
ALREADY_EXISTS; deleting what is not there is OK. - It never touches what it did not create. ZFS: the dataset's own
fileshare:volumeproperty — set locally, since user properties are inherited and an inherited mark is not ours. btrfs: the subvolume id and uuid recorded instate_file. XFS/ext4: a project id inside the parent's range and recorded. Checked before every resize, snapshot and delete. - Delete refuses data and snapshots unless
destroy_datais set, and is logged before it happens. - ⛔
fileshare servemust not run as root nor withCAP_SYS_RESOURCE: ext4 lets either write past a project quota. Soclient_uid = 0is refused, and fileshare writes into a volume through its group, never as its owner — the owner of a directory may clear its project id without privilege.
A systemd unit for it:
[Service]
ExecStart=/usr/local/bin/fileshare provisioner -c /etc/fileshare-provisioner.hcl
RuntimeDirectory=fileshare-provisioner
RuntimeDirectoryMode=0755
StateDirectory=fileshare-provisioner
StateDirectoryMode=0700
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_CHOWN CAP_FOWNER
# Run as root, or as a dedicated user with:
# AmbientCapabilities=CAP_SYS_ADMIN CAP_CHOWN CAP_FOWNER
NoNewPrivileges=yes
# The quota calls are named rather than trusted to a group. (An example:
# CI runs the provisioner as root under sudo, not under this unit.)
SystemCallFilter=@system-service @mount quotactl quotactl_fd
SystemCallArchitectures=native
LockPersonality=yes
RestrictRealtime=yes
# NOT ProtectSystem= nor ReadWritePaths=: they give the service its own
# mount namespace, and the ZFS volumes it mounts would be seen by nobody
# else. And no Landlock: a Landlock-restricted thread cannot mount(2).
The socket lives in its own directory (/run/fileshare-provisioner), not
in fileshare's: fileshare must be able to write its own admin socket in
/run/fileshare, and the provisioner refuses a socket directory anybody but
itself may write into.
Volumes
With a provisioner running, the admin API creates storage and serves shares from it:
| call | does |
|---|---|
ListParents |
where volumes may be created, their kind, free and total space |
CreateVolume / ResizeVolume |
a quota'd volume, idempotent by name |
SnapshotVolume |
a read-only snapshot (ZFS, btrfs) |
GetVolume / ListVolumes |
each volume, with the shares that use it |
DeleteVolume |
refused while a share uses it; DeleteShare never deletes a volume |
CreateShare with volume { parent, name } |
a share served from it, as a directory |
The provisioner's status codes come back as it gave them — ALREADY_EXISTS,
RESOURCE_EXHAUSTED, OUT_OF_RANGE, NOT_FOUND, FAILED_PRECONDITION,
UNIMPLEMENTED. Without a provisioner every one of these is
FAILED_PRECONDITION; a provisioner that refuses this server's uid is
FAILED_PRECONDITION too (its client_uid is wrong), one that does not answer
UNAVAILABLE.
Before a volume is served, fileshare asks the provisioner where it is and
checks, itself: that the path is clean, absolute and — resolved — under
source_roots; that it is a directory; that statfs reports the kind's
filesystem (ZFS_SUPER_MAGIC, BTRFS_SUPER_MAGIC, XFS_SUPER_MAGIC,
EXT4_SUPER_MAGIC); for ZFS that it is a mount point (its device is not its
parent's — an unmounted dataset's directory would take writes under no
quota); for btrfs that it is a subvolume's root; for XFS and ext4 that it
carries a project id its new files inherit. The state file keeps the volume's
name, and every start asks and checks again: a volume that is gone or
fails a check leaves its share defined and not served — said at the
start, by check, and in the share's unavailable field — while the rest of
the server starts. EnableShare tries it again.
⛔ fileshare serve refuses to serve volumes while it runs as root or holds
CAP_SYS_RESOURCE (read from /proc/self/status): ext4 lets either write
past a project quota — root wrote 16 MiB into an 8 MiB project in
go-fsctl/projquota's CI. fileshare check says which it is.
A full share says so on every protocol. XFS says a full project with
ENOSPC, ext4, btrfs and ZFS with EDQUOT (Windows: ERROR_DISK_FULL,
ERROR_DISK_QUOTA_EXCEEDED):
| protocol | no space (ENOSPC) |
quota (EDQUOT) |
|---|---|---|
| WebDAV | 507 Insufficient Storage |
507 Insufficient Storage |
| NFS | NFS3ERR_NOSPC (28) |
NFS3ERR_DQUOT (69), RFC 1813 §2.6 |
| SMB | STATUS_DISK_FULL (0xC000007F) |
STATUS_DISK_FULL, as Samba answers |
| SFTP | SSH_FX_FAILURE, "no space left on device (the share is full)" — version 3 has no code for it |
the same |
| S3 | served read-only |
Both processes, configured:
# /etc/fileshare/fileshare.hcl -- `fileshare serve`, as user fileshare (uid 990)
admin {
listen = "unix:///run/fileshare/admin.sock"
state_file = "/var/lib/fileshare/shares.json"
source_roots = ["/srv/fileshare/volumes"]
provisioner = "unix:///run/fileshare-provisioner/provisioner.sock"
provisioner_uid = 0 # who must answer on that socket; 0 is the default
allowed_uids = [990] # who may call this API
}
# /etc/fileshare-provisioner.hcl -- `fileshare provisioner`, as root
provisioner {
listen = "unix:///run/fileshare-provisioner/provisioner.sock"
client_uid = 990
group = "fileshare"
max_volume = "10T"
state_file = "/var/lib/fileshare-provisioner/volumes.json"
parent "tank" {
zfs = "tank/fileshare"
root = "/srv/fileshare/volumes/tank"
}
parent "fast" { btrfs = "/srv/fileshare/volumes/fast" }
parent "plain" {
xfs = "/srv/fileshare/volumes/plain"
project_ids = "100000-199999"
}
}
and the pair of units — the provisioner's is above:
# fileshare.service
[Unit]
Requires=fileshare-provisioner.service
After=fileshare-provisioner.service
[Service]
User=fileshare
Group=fileshare
ExecStart=/usr/local/bin/fileshare serve -c /etc/fileshare/fileshare.hcl
RuntimeDirectory=fileshare
StateDirectory=fileshare
# No capability at all: CAP_SYS_RESOURCE would let it past an ext4 quota,
# and fileshare refuses to serve volumes with it.
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/srv/fileshare/volumes /var/lib/fileshare
fileshare must be in the provisioner's group: a volume root is
root:fileshare 2770, and fileshare writes through the group, never as the
owner. ProtectSystem= is fine here: it is the provisioner that mounts,
and ReadWritePaths= keeps a ZFS volume mounted after the server started
visible — mounts propagate into the server's namespace, not out of it. (Not
exercised in CI: the end-to-end job runs both processes under sudo.)
Health and metrics
metrics { listen = "127.0.0.1:9100" } # or unix:///run/fileshare/metrics.sock
/healthz (the process answers), /readyz (every protocol is bound and a
generation is serving — 503, and why, while starting, applying a change or
stopping) and /metrics in Prometheus text format, served by
go-net-health/endpoint on a
listener of its own, never a public port.
fileshare_shares{origin}, fileshare_generation,
fileshare_connections_accepted_total{protocol},
fileshare_connections_open{protocol}, fileshare_admin_changes_total{result},
fileshare_admin_requests_total{method,code}, fileshare_directory_people,
fileshare_directory_reloads_total{result}, the Go runtime, build info.
⛔ No metric names a share or a person: WebDAV answers 404 for a share
somebody may not use, so that it is not confirmed to exist, and a scrape must
not confirm it either.
Reading the directory again
reload = "5m" # and on SIGHUP, and on the admin API's ReloadDirectory
The people come from directories other things change — go-authn/bridge writes an application password into a table, and deletes the row when the person is disabled. A reload reads them again, and what it does depends on what changed:
- only additions — somebody new, who changes no share's lists (a new
application password, a person the shares reach through
oidc:rules or none at all): added in place, SMB's running server included, and no connection is touched. Somebody new who joins a group a share names does change that share, and goes through a new generation like any other change to it: SMB fixes a share's lists when it starts; - anything taken away — somebody gone, a credential changed, a share whose expanded lists changed: a new generation, and the old one's connections closed, so a removal reaches the sessions already open;
- a directory that cannot be read — nothing changes. An outage must not empty a file server.
⛔ A share written for @engineers whose last engineer has left is served to
nobody, not to everybody: an empty allow means "anyone who
authenticates", so what decides whether a share is open is what was
written, not what it expands to now. Such a share is not offered over SMB at
all, whose empty AllowUsers would read as everyone.
⛔ The same holds one level up: a directory that is read and holds nobody
— the last application password deleted — leaves a server whose people are
gone, not an anonymous one. Whether WebDAV asks for a password is decided by
whether the configuration has a user or users block (or a provider), never
by how many people the directory holds at the moment; up to v0.16.7 an emptied
directory turned "anyone who authenticates" into anyone, read-write. SMB, SFTP
and S3 have no anonymous mode at all, and NFS serves only unrestricted shares
by what the configuration says.
It is the users blocks — SQL, LDAP — that are read again; the user blocks
of the configuration file are the configuration, read at the start.
TLS, and certificates from ACME
WebDAV and S3 are served over HTTPS, and NFS over RPC-with-TLS (RFC 9289),
with tls = true on their serve block and one tls block saying where the
certificate comes from — files, reloaded when they change:
tls {
cert_file = "/etc/fileshare/tls/fullchain.pem"
key_file = "/etc/fileshare/tls/key.pem"
}
serve "webdav" {
addr = "0.0.0.0:443"
tls = true
}
— or an ACME CA, through go-authn/servercert. Let's Encrypt by default; a CA that knows you through external account binding, such as GÉANT TCS through HARICA:
tls {
acme {
directory_url = "https://acme-v02.harica.gr/acme/<uuid>/directory" # the Server URL cm.harica.gr shows
domains = ["files.example.org"]
cache_dir = "/var/lib/fileshare/acme"
eab_key_id = "…"
eab_hmac_key_file = "/etc/fileshare/tls/eab.key" # a secret: a file, never the config
}
}
What decides whether ACME can work is how the CA checks the name:
| challenge | the CA connects to | so |
|---|---|---|
| tls-alpn-01 (RFC 8737) | port 443 | a TLS protocol must be served on 443 |
| http-01 (RFC 8555 §8.3) | port 80 | http_challenge = "0.0.0.0:80" answers it |
| none | nothing | a CA account with the domain pre-validated asks for no challenge: HARICA's enterprise EAB accounts for GÉANT TCS — so a server nobody outside can reach still gets its certificate |
A certificate is asked for at the first TLS connection that names the host (SNI); a client connecting by IP address gets none.
⛔ WebDAV with passwords is no longer served in the clear on an address
other machines can reach. HTTP Basic is the password on every request and a
bearer token is as good as one, so such a configuration is refused, not
warned about. Serve it with tls = true, or — when TLS is terminated in front
of it, by a proxy — say so with plaintext = true. Loopback addresses, and a
server with nobody to authenticate, are unaffected. fileshare check says, per
protocol, what is encrypted and what is in the clear on purpose.
NFS over TLS proves the machine, not the person. client_ca_file makes a
client present a certificate from that authority — which host is mounting —
and RFC 9289 leaves user authentication as it was: the uid inside is still the
one AUTH_SYS claims. So a share that names who may use it is still refused over
NFS without a kerberos block. TLS is offered, not required: a client that
never asks for it is still served the open shares. SMB and SFTP do not take
tls: SMB 3 encrypts with its own keys, and SFTP is SSH.
NFS, with identities from certificates
serve "nfs" {
tls = true
client_ca_file = "/etc/fileshare/bridge-x509-ca.pem"
identity = "certificate"
crl_url = "https://bridge.example.org/x509/crl" # or crl_file; required
crl_state_file = "/var/lib/fileshare/nfs.crl.state" # the order, across a restart
}
After an identity provider login, go-authn/bridge issues a short-lived X.509
client certificate naming the person the way FreeBSD's rpc.tlsservd -u reads
it — the SubjectAltName otherName 1.3.6.1.4.1.2238.1.1.1, a UTF8String
user@domain (draft-cel-nfsv4-rpc-tls-othername
describes it; its OIDs are not assigned yet) — and their groups as
tag:go-authn.github.io,2026:group:… URIs (RFC 4151). With identity = "certificate", a share that names people is served over NFS without kerberos:
every call on it must arrive over TLS, with a certificate that is not in the
CRL (fetched and kept failing closed, like the KRL above — a CRL past its
NextUpdate counts as unknown), that names exactly one person, somebody this
server admits the way it admits a token, and whom the share allows.
⛔ What it cannot promise, and why RFC 9289 alone refuses to: the server sees
a connection's certificate, and a Linux client attaches one to a mount
(tlshd, the keyring serial on mount -o xprtsec=mtls,...). Every user of that
mount acts as the person the certificate names. On a workstation one person
uses, that is exactly them; on a machine several people log into, it is whoever
mounted — use sec=krb5 there. The certificate's lifetime bounds a revocation
only if the CRL is not reachable; with it, the next call after the CRL changes
is refused.
Measured with a real Linux client (kernel 6.17, ktls-utils 0.9, in go-filesystems/nfs's CI):
- MOUNT's MNT arrives in the clear, whatever
xprtsec=says: the kernel's mount client has no TLS. It is answered, and every NFS call made without TLS is then refused, so a person the share does not allow sees access denied at the first access rather than atmount. Nothing of a share crosses in the clear. tlshdchecks this server's certificate against the system trust store only (it ignoresx509.truststore), and wants the client's certificate and key owned by root, the key mode 600 — otherwise it fails with gnutls: Error in the certificate (-43), naming neither.
One image, several protocols, one lock
Every server in this family serialises the driver itself, because
go-filesystems/interface
promises nothing about concurrent path-based calls — and each of them holds
its own lock, knowing nothing about the others. Serving one image over
three protocols at once would therefore have no common lock anywhere, so the
image is wrapped once here and the same wrapper is handed to all of them.
Measured, so as not to overstate it: eight goroutines writing through an
unwrapped fat32 driver under -race produce no race today. This is insurance
on a contract, not a reproduction of a defect — ext4 and ntfs are not fat32,
and a driver update is not a thing this program should have to re-audit.
The wrapper carries the optional capabilities through rather than hiding them:
a driver that answers Opener gets a locked File back, and one whose File
answers WritableFile keeps its positional writes. The difference between
those and the whole-file fallback was measured at ~70× elsewhere in the family,
so a wrapper that erased them would be a performance defect disguised as
safety.
Verified
- macOS mounts a share over SMB while
curlis writing to the same image over WebDAV, and reads back what WebDAV wrote. Bob's WebDAV write to a share he may only read is403; a wrong password is401. - go-smb2 (a client this project did not write) drives twenty concurrent
reads through SMB while twenty run through WebDAV, under
-race, in CI. - The access rules are checked through every protocol rather than in the configuration alone: alice writes and bob does not, over SMB and over WebDAV, and NFS is not offered the share at all.
S3: a share is a bucket
serve "s3" { addr = "0.0.0.0:9000" }
The fifth protocol, and the one that makes an image reachable from anything
that speaks object storage — a backup tool, a data pipeline, rclone, a
browser’s fetch(). Nothing mounts.
GET / the shares this person may use, as buckets
GET /photos?list-type=2 the files in that share, as keys
GET /photos/holiday.jpg the file itself, Range and all
It is served over the same per-user tree SFTP uses, so a share alice may
not use is not a bucket alice can see — and the access rules are applied in
one place rather than copied into a second protocol. A share's
protocols = [...] holds here as everywhere: a share that does not name s3
is not a bucket for anybody (up to v0.16.7 S3 ignored the list).
An access key is a user, and the secret key is their password. SigV4
proves possession by computing an HMAC, so the directory must hold the
password: the same column as NTLMv2, and check prints it. An identity
holding only an NT hash serves SMB and not S3 — MD4 is not a secret an
HMAC can be built from.
⛔ The secret never leaves the directory. Identity.Derive runs the key
schedule over the password and hands back only the result, which is why there
is no Password() accessor anywhere in this program.
⛔ The access key is read before any signature is checked, so it is a claim and nothing is built from it: a key the directory does not know is answered by one shared server that knows no secret, and only the people the directory knows get a server of their own — at most 4096 kept at once. Up to v0.16.7 every unsigned request with a fresh key pinned a server and the key itself.
The object API itself is go-filesystems/s3:
ListBuckets, ListObjectsV2 with prefix and delimiter, HeadObject,
GetObject with Range. Costs 0.1 MB — SigV4 is stdlib crypto.
Not yet
Writes over S3. PUT and DELETE answer 403. The library can write, but
a share a person may only read has to refuse them at the same place SFTP
does, and that is not wired yet — refusing is better than a half-written
object. Multipart upload is refused by name, so a client falls back to a
single PUT rather than failing at the end of a 5 GB one.
Licence
BSD-3-Clause.
Documentation
¶
Overview ¶
Command fileshare serves disk images over SMB, NFS, WebDAV and SFTP -- the same images, the same users, the same per-share access, from one configuration file.
fileshare --config /etc/fileshare.d fileshare check /etc/fileshare.d
A person wants to SHARE an image. Which protocol carries it is a property of the client at the other end: macOS and Windows reach for SMB, a Linux fleet already has NFS, a browser or a phone has HTTP. Running four servers, each with its own configuration file and its own idea of who "alice" is, is a way to get three of them subtly wrong.
Which filesystems ¶
The one inside an image is worked out rather than declared, by go-filesystems/detect: fat32, exfat, ext4, ntfs, ufs, iso9660, squashfs and hfsplus -- every driver in that organisation of the one shape, OpenReader(io.ReaderAt, int64).
apfs, btrfs, xfs and zfs cannot be recognised that way: each opens a DISK image and picks a partition, so there is no magic at offset zero to find. A share says which, and may also say which partition:
share "photos" {
image = "/srv/disk.img"
filesystem = "xfs"
partition = 2 # default -1: the first data partition
}
A partition is chosen for ANY filesystem, not only those four: a disk image holding FAT32 has a table in front of it as often as one holding XFS does, and detection reads offset zero. Say partition, partition_label or partition_uuid -- one of them, never two -- and the driver is handed a view of that partition. An INDEX moves when a disk is repartitioned; a label or a UUID names the partition itself. A share that chose one is read-only, and says so at startup.
⛔ Naming a filesystem turns detection OFF for that share, so the image is opened as that or REFUSED -- a FAT32 image told it is XFS fails to start rather than becoming an XFS share. The sniffable ones may be named too, and then what was claimed is compared with what was found, which is how a site refuses a misdetection instead of discovering one. `check` marks a named driver with an asterisk: that row was asserted, not recognised.
What a protocol can promise ¶
The protocols do not agree about the one thing access control needs: whether the server can tell WHO is asking.
SMB NTLMv2. The server proves the password without ever seeing it.
WebDAV HTTP Basic over the transport's TLS, or a bearer token an
identity provider signed (see the oidc block; nothing else here
has anywhere to put one).
SFTP A public key, or an SSH certificate from an authority you trust.
NFSv3 NOTHING. AUTH_UNIX is a claim -- the client says "uid 501" and
the wire cannot disagree. There is no encryption either.
So a share that names who may use it is NOT exported over a protocol that cannot tell them apart. It is not a warning and not an option: a configuration that says "photos belongs to alice" and a protocol that hands photos to whoever connects cannot both be honoured, and quietly widening access is the worse of the two failures.
Where the people come from ¶
A user block is the whole directory for a household; a site that already has its people in a database or in LDAP names them where they are instead:
users "sql" { driver, dsn_file, and YOUR queries }
users "ldap" { url, base_dn, bind_dn, bind_password_file }
Sources are asked in the order they are written and the first that knows a name owns it, so a service account written down locally is not overridden by somebody with the same name in LDAP. A group is written @name wherever a person could be, and its members are the union of every source.
What a source can prove decides which protocols can serve somebody, and the awkward half is worth saying here: NTLMv2 needs the password or its MD4, so a directory that only CHECKS passwords -- an LDAP bind, a bcrypt column -- cannot answer SMB however good the check is, while WebDAV asks exactly the question a bind answers. See github.com/go-authn/directory, whose model this is, and Identity.Can, which is why check can print it per person.
A directory, the admin API, and what a supervisor asks ¶
A share may serve a directory of the host -- `directory = "/data"` in place of `image` -- confined with os.Root; see hostdir.go. An `admin` block turns on a gRPC API that creates shares and grants them to people and groups, kept in a state file of its own; see admin_grpc.go, state.go and, for what a change does to the servers already running, generation.go. A `metrics` block serves /healthz, /readyz and /metrics; see metrics.go.
`fileshare check` prints the whole matrix -- every share against every protocol, and who may read and write it -- because that is the question a person actually has before they restart a server other people are using.
Source Files
¶
- acme_prefetch.go
- admin_grpc.go
- admin_web.go
- admin_webconfig.go
- caep.go
- capacity.go
- config.go
- device.go
- device_aligned.go
- device_size_linux.go
- device_unix.go
- directory.go
- doc.go
- domaingrant.go
- federated_sftp.go
- fullfs.go
- fullfs_unix.go
- generation.go
- hangup_unix.go
- hostdir.go
- identity.go
- identity_unix.go
- isolate.go
- isolate_unix.go
- krl_sftp.go
- lockedfs.go
- main.go
- metrics.go
- nfs_identity.go
- oidcauth.go
- opkssh.go
- partition.go
- partitioned.go
- passwd.go
- principal.go
- protocol.go
- protocol_nfs.go
- protocol_s3.go
- protocol_sftp.go
- protocol_smb.go
- protocol_webdav.go
- provisioner.go
- provisioner_on.go
- reload.go
- revocation.go
- root.go
- serve_nfs.go
- serve_s3.go
- serve_sftp.go
- serve_smb.go
- serve_webdav.go
- server.go
- share.go
- sqldrivers.go
- ssf.go
- sshconn.go
- state.go
- tls.go
- unionfs.go
- volume.go
- volume_grpc.go
- volume_linux.go
- worker.go
- worker_unix.go
- workerpool.go
Directories
¶
| Path | Synopsis |
|---|---|
|
hack
|
|
|
archive
command
Command archive packs a directory into a .tar.gz or a .zip that depends on nothing but its input and the Go that runs it: entries in byte order, owner 0:0, one modification time for every entry, no name or time in the gzip header.
|
Command archive packs a directory into a .tar.gz or a .zip that depends on nothing but its input and the Go that runs it: entries in byte order, owner 0:0, one modification time for every entry, no name or time in the gzip header. |
|
internal
|
|
|
account
Package account resolves a person to the system account a worker runs as: a uid, a primary gid and supplementary gids (docs/identity.md).
|
Package account resolves a person to the system account a worker runs as: a uid, a primary gid and supplementary gids (docs/identity.md). |
|
launch
Package launch starts the workers that serve a person's files as that person (docs/identity.md).
|
Package launch starts the workers that serve a person's files as that person (docs/identity.md). |
|
peercred
Package peercred is gRPC transport credentials for a unix socket that say WHO is at the other end: the uid, gid and pid the kernel recorded for the peer when it connected (SO_PEERCRED on Linux, LOCAL_PEERCRED on macOS).
|
Package peercred is gRPC transport credentials for a unix socket that say WHO is at the other end: the uid, gid and pid the kernel recorded for the peer when it connected (SO_PEERCRED on Linux, LOCAL_PEERCRED on macOS). |
|
provision
Package provision is `fileshare provisioner`: the privileged half of fileshare, which creates the storage a volume share is served from -- a ZFS dataset, a btrfs subvolume, an XFS or ext4 directory under a project quota -- and nothing else.
|
Package provision is `fileshare provisioner`: the privileged half of fileshare, which creates the storage a volume share is served from -- a ZFS dataset, a btrfs subvolume, an XFS or ext4 directory under a project quota -- and nothing else. |
|
provisionclient
Package provisionclient is how `fileshare serve` talks to `fileshare provisioner`: a unix socket, typed calls, and nothing that could carry a path.
|
Package provisionclient is how `fileshare serve` talks to `fileshare provisioner`: a unix socket, typed calls, and nothing that could carry a path. |
|
proto
|
|