Documentation
¶
Overview ¶
Package webdav implements a read/write WebDAV server (RFC 4918) that exports any github.com/go-filesystems/interface.Filesystem as an net/http.Handler.
It turns every go-filesystems driver — ext4, xfs, btrfs, zfs, ntfs, fat32, exfat, hfsplus, apfs, iso9660, squashfs, ufs, ffs, uefi, oci — into something a browser can read, a Finder or Explorer window can mount, and `curl` can drive, from one pure-Go binary with no cgo and no root.
Why WebDAV alongside NFS ¶
github.com/go-filesystems/nfs already makes a Filesystem mountable. This module exists because HTTP gives three things NFSv3 structurally cannot, and each of them decided part of this design:
- Authentication and confidentiality. NFSv3's AUTH_UNIX is a claim, not a proof: the client says "uid 501" and the wire cannot disagree, and there is no encryption at all. That is acceptable on 127.0.0.1 and unusable anywhere else. WebDAV is HTTP, so it gets Basic and Bearer authentication and TLS from the transport underneath it — see WithBasicAuth, WithBearerAuth and the TLS note below.
- A write model that matches the contract as it stands today. NFS WRITE names an offset, and github.com/go-filesystems/interface.Filesystem has no positional write, so the NFS server has to read the file, splice and write it back — O(filesize) per request, measured at 90 kB/s over a real mount. WebDAV PUT sends the whole body and is therefore exactly github.com/go-filesystems/interface.Filesystem.WriteFile: one write of one file, at the driver's own speed. And where a client does want a partial write, a PUT carrying a Content-Range is served through github.com/go-filesystems/interface.WritableFile — one WriteAt, not a read-splice-write — or refused 501 if the driver has no positional write, rather than emulated at the cost the client was trying to avoid.
- A client every machine already has. A GET on a file returns its bytes, so a browser, `curl`, `wget` and every HTTP library are clients without mounting anything.
The cost is equally honest: WebDAV is not a POSIX filesystem. There are no file descriptors, no byte-range locks, no partial writes, and a mounted WebDAV volume rewrites a whole file on every save.
Serving one ¶
The handler is an ordinary net/http.Handler, which is what makes it cheap to embed and cheap to run many of: one image per tenant, one Handler per image, either on one mux or one process each.
fsys, err := fat32.Open("disk.img", -1)
if err != nil {
return err
}
defer fsys.Close()
h, err := webdav.New(fsys, webdav.ReadWrite())
if err != nil {
return err
}
return http.ListenAndServe("127.0.0.1:8080", h)
TLS is the caller's, deliberately ¶
This package does not open a listener and has no TLS configuration of its own. A net/http.Handler is served over TLS by the net/http.Server that wraps it, which is where a caller's certificates, key material, ALPN, and client-certificate policy already live:
srv := &http.Server{Addr: ":8443", Handler: h, TLSConfig: myTLSConfig}
return srv.ListenAndServeTLS("", "")
Owning a TLSConfig here would mean either reading key material from a fixed path — which an embedded caller must never have decided for it — or duplicating a configuration surface the standard library already has. Nothing in this package reads a file, an environment variable or a well-known location: every credential, every certificate and the filesystem itself arrive as arguments.
Isolation ¶
The isolation boundary is the image file and the process, not this code. A Handler can reach exactly one Filesystem and nothing else: it never opens a host path, never resolves a name against the host, and every request path is normalised with ".." clamped at the root before it is used, so no URL — including one that spells the traversal "%2e%2e" — can name anything outside the image. Symbolic links are read, not followed: a link's target is reported as a property and is never resolved by this server.
Security posture ¶
Exports are read-only unless ReadWrite is passed; most of what this is pointed at is a forensic or build artefact, and an accidental write to one is unrecoverable.
Authentication is optional and, when configured, refused over cleartext: Basic sends the password in every request with nothing but base64 over it, so a Handler with credentials configured answers 426 Upgrade Required on a plaintext connection unless the peer is loopback or the caller passed AllowInsecureAuth. See WithBasicAuth.
Index ¶
- Constants
- Variables
- func Verify(got, want string) bool
- type Handler
- type Option
- func AllowInsecureAuth() Option
- func ReadWrite() Option
- func WithBasicAuth(realm string, verify func(user, pass string) bool) Option
- func WithBearerAuth(realm string, verify func(token string) bool) Option
- func WithCapacity(total, avail uint64) Option
- func WithMaxBody(n int64) Option
- func WithPrefix(prefix string) Option
- type TimeStat
Constants ¶
const ( // StatusMulti is 207 Multi-Status (RFC 4918 §11.1): the body carries one // status per resource. StatusMulti = 207 // StatusUnprocessable is 422 Unprocessable Entity (RFC 4918 §11.2): // well-formed XML the server cannot act on. StatusUnprocessable = 422 // StatusLocked is 423 Locked (RFC 4918 §11.3). StatusLocked = 423 // StatusFailedDependency is 424 Failed Dependency (RFC 4918 §11.4): this // resource was not touched because another one in the same request // failed. StatusFailedDependency = 424 // StatusInsufficientStorage is 507 Insufficient Storage (RFC 4918 §11.5). StatusInsufficientStorage = 507 )
WebDAV extends HTTP's status codes; the ones RFC 4918 adds and this module uses are spelled out because net/http has no constants for them.
Variables ¶
var ( // ErrLocked reports a resource locked by a token the request did not // submit. It becomes 423 Locked. ErrLocked = errors.New("webdav: resource is locked") // ErrNoSuchLock reports a token that names no live lock. It becomes 409 // Conflict for UNLOCK and 412 for a refresh, per RFC 4918. ErrNoSuchLock = errors.New("webdav: no such lock") // ErrLockTableFull reports the lock table hitting maxLocks. ErrLockTableFull = errors.New("webdav: lock table full") )
Lock errors, mapped to wire statuses by [writeLockError].
var ( // ErrNilFilesystem reports New called with no filesystem. It is caught // here because the alternative is a nil dereference on the first request // — long after the mistake, in a connection goroutine, with no recover. ErrNilFilesystem = errors.New("webdav: nil filesystem") // ErrPrefix reports a prefix that is not a clean absolute path without a // trailing slash, e.g. "/files". ErrPrefix = errors.New("webdav: prefix must be a clean absolute path with no trailing slash") // ErrNilVerifier reports an authentication option given no verifier. A // nil one would accept every credential, which is worse than no // authentication at all because it looks like some. ErrNilVerifier = errors.New("webdav: nil credential verifier") )
Errors returned by New.
Functions ¶
func Verify ¶
Verify reports whether got equals want without leaking, through timing, where the two first differ.
It exists so that the obvious implementation of a WithBasicAuth verifier is also the correct one. A verifier written with == compares byte by byte and stops at the first mismatch, which lets a client recover a secret one character at a time; that is a real attack on a network service and an easy one to write by accident. It does not hide the length, which no comparison can.
Types ¶
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler serves one github.com/go-filesystems/interface.Filesystem over WebDAV. It is an ordinary net/http.Handler: it opens no listener, holds no TLS configuration and reads no file, so a caller composes it with net/http.ServeMux, wraps it in whatever middleware it already has, and terminates TLS in its own net/http.Server.
That is also what makes the intended deployment cheap: one disk image per tenant, one Handler per image, either many on one mux or one per process. A Handler can reach exactly one Filesystem and nothing else.
The zero value is not usable; call New.
func New ¶
func New(fsys filesystem.Filesystem, opts ...Option) (*Handler, error)
New returns a Handler exporting fsys, read-only unless ReadWrite is passed.
It returns an error if an option is malformed, or if the system CSPRNG is unavailable — which would make lock tokens guessable, so the server refuses to start rather than start insecurely (see lock.go).
func (*Handler) ServeHTTP ¶
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP implements net/http.Handler.
type Option ¶
Option configures a Handler.
func AllowInsecureAuth ¶
func AllowInsecureAuth() Option
AllowInsecureAuth permits credentials over a cleartext, non-loopback connection.
Without it, a Handler that has authentication configured answers 426 Upgrade Required on such a connection instead of reading the Authorization header, because a Basic password crosses that link in the clear on every single request and a server that accepts it teaches its users to send it. Loopback is already exempt without this option — there is no link to intercept — and so is any request that arrived over TLS.
It exists for the one honest case: a Handler already behind a TLS terminator the caller trusts, reached over a private link. Passing it because a certificate was inconvenient is how the password leaks.
func ReadWrite ¶
func ReadWrite() Option
ReadWrite makes the export writable.
Exports are read-only by default, following github.com/go-filesystems/nfs: most of what this module is pointed at is a forensic or build artefact, and an accidental write to one is unrecoverable. A read-only Handler answers every mutating method 403 Forbidden and does not advertise them in OPTIONS, so a client's own check agrees with the server's before it tries.
func WithBasicAuth ¶
WithBasicAuth requires HTTP Basic credentials that verify accepts.
The verifier is a function rather than a user/password pair on purpose: this package must never be the place a credential is written down. Where the caller's credentials come from — a keyring, a database, an environment the caller controls, a token service — is the caller's business, and a callback is the only shape that does not force it into this API. Nothing here is read from a fixed location.
The verifier is called with attacker-controlled strings and must compare in constant time; Verify does that for the common case of a fixed credential the caller holds in memory.
Basic sends the password on every request with nothing but base64 over it, so a Handler with any authentication configured refuses to read credentials from a cleartext, non-loopback connection at all: see AllowInsecureAuth.
Basic and Bearer may both be configured; a request satisfying either is accepted, and the challenge offers both.
func WithBearerAuth ¶
WithBearerAuth requires an RFC 6750 bearer token that verify accepts. The same rules as WithBasicAuth apply: the verifier is supplied by the caller, must compare in constant time, and is not consulted over a cleartext non-loopback connection.
func WithCapacity ¶
WithCapacity sets the total and available byte counts reported by the RFC 4331 quota properties, which is what a mounted volume shows as its size and free space.
It exists because github.com/go-filesystems/interface.Filesystem has no statfs operation, so this module genuinely cannot know. Rather than invent a plausible number — which would make a volume's free-space display confidently wrong, and would make a client refuse a write it could actually have done — a Handler with no capacity set omits both properties, which clients read as "unknown", and the caller who does know (it opened the image, so it knows its size) can say so.
func WithMaxBody ¶
WithMaxBody bounds the number of bytes a PUT may carry. See [defaultMaxBody] for why there is a bound at all. A value <= 0 restores the default rather than removing the bound, because "no limit" is not something this server can offer: the body is buffered in full.
func WithPrefix ¶
WithPrefix mounts the export under a URL prefix, e.g. "/files". The prefix must be a clean absolute path with no trailing slash.
It exists rather than deferring to net/http.StripPrefix because a multistatus body quotes absolute hrefs back to the client: a stripped prefix would be missing from every href in it, and the client would follow them to the wrong place. The Handler therefore has to know its own mount point rather than have it hidden from it.
type TimeStat ¶
type TimeStat interface {
ModTime() int64 // seconds since the Unix epoch
}
TimeStat is the optional capability a driver's github.com/go-filesystems/interface.Stat may implement to report a real modification time.
No driver in the fleet does today, so every resource this server exports currently reports the Handler's start time as its getlastmodified. The probe exists so that the day a driver starts reporting mtime, clients get real times without a change here — and so that the gap is visible in the API instead of buried in a comment. It is spelled exactly as github.com/go-filesystems/nfs.TimeStat so a driver satisfies both servers with one method.
