Documentation
¶
Overview ¶
Package sftp implements an SFTP version 3 server (draft-ietf-secsh-filexfer-02) that exports any github.com/go-filesystems/interface.Filesystem.
Every driver in the family — ext4, xfs, btrfs, zfs, ntfs, fat32, exfat, hfsplus, apfs, iso9660, squashfs, ufs, ffs, uefi, oci — becomes something a person can reach from a file manager, an editor or a shell, over one TCP port, with SSH keys and encryption, from a pure-Go binary with no cgo.
Why SFTP ¶
The client already exists, everywhere, and nobody has to install it.
- `sftp` on the command line ships with OpenSSH, which is present on macOS, on every Linux distribution, on the BSDs, and on Windows 10 and later as an optional feature that is on by default in current builds.
- Graphical clients are abundant and free or cheap: Cyberduck, FileZilla, WinSCP, Transmit.
- Editors speak it natively: VS Code, the JetBrains IDEs, Emacs by way of TRAMP.
- GNOME mounts sftp:// through gvfs as an ordinary unprivileged user, with no root and no entry in the kernel's mount table.
And the security is not bolted on. Authentication is SSH public keys and the channel is encrypted, both from golang.org/x/crypto/ssh — see github.com/go-filesystems/sftp/sshd. NFSv3, the sibling of this module, has neither: its AUTH_UNIX credentials are claims a client makes about itself and the wire cannot disagree. It is one TCP port, so a firewall can reason about it, and nothing needs privilege on either side: no mount, no kernel extension, no FSKit entitlement, no root.
The isolation this is built for ¶
One image file per client, one server process per client, each with its own keys. The isolation boundary is the image file plus the process — not a chroot, not uid mapping, not namespaces.
That is why Server takes exactly one github.com/go-filesystems/interface.Filesystem and holds no other capability. It never touches the host filesystem, never resolves a path against anything but the driver it was handed, and cannot be induced to: a client path is cleaned with ".." clamped at the root before it reaches the driver, so no sequence of components names anything outside the image, and a symbolic link inside the image resolves inside the image because the driver is the only thing doing the resolving. The tests assert this directly rather than leaving it to be inferred.
A Server is cheap: no goroutine until a session arrives, no listener of its own, no global state. Running a hundred of them on one machine, one per tenant, is the intended shape, and Server.Serve is deliberately handed a stream rather than a listener so that an embedding program — a microVM supervisor, say — can wire it into an SSH server it already runs.
Serving one ¶
fsys, err := fat32.Open("tenant.img", -1)
if err != nil {
return err
}
defer fsys.Close()
srv, err := sftp.New(fsys) // read-only by default
if err != nil {
return err
}
// Then either hand srv.Serve a stream you own, or let the sshd
// subpackage own the SSH side:
d, err := sshd.New(srv, sshd.Config{HostKeys: ..., AuthorizedKeys: ...})
Two honest gaps, both in the contract and not in this server ¶
Reads. github.com/go-filesystems/interface.Opener is exactly the shape of SSH_FXP_READ: OpenFile(path) returns a File whose ReadAt(buf, off) answers the request as asked. This module uses it when a driver has it. At the time of writing NO driver in the fleet implements it, so in practice every read goes through the fallback, which calls ReadFile — materialising the WHOLE file — once per READ request. An OpenSSH client reads in 32 KiB chunks, so fetching an n-byte file costs n/32768 full-file reads: quadratic. That is measured in the README rather than described.
The tempting fix — read the file once when the handle is opened and serve reads from that copy — is not taken, because it trades a quadratic time cost for a linear MEMORY cost and a 4 GiB image file becomes 4 GiB of resident memory in a process meant to run a hundred times over. The real fix is a driver implementing Opener, and leaving the cost visible is what keeps that pressure where it belongs.
Writes. SSH_FXP_WRITE carries an offset, and the base contract has only WriteFile, which replaces a file whole. A write at an offset therefore becomes read-modify-write of the entire file: O(filesize) per request, and O(filesize²) for a client streaming a file in chunks. This is the same wall github.com/go-filesystems/nfs hit and measured at 90 kB/s. Exports are consequently READ-ONLY BY DEFAULT — see ReadWrite — and the number for this module is in the README.
The escape is WritableFile: a File that also has WriteAt, Truncate and Sync. When a driver's File satisfies it, writes go to the offset directly and the wall disappears. The probe is here and tested; the implementations belong in the drivers.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrNilFilesystem = errors.New("sftp: nil filesystem")
ErrNilFilesystem reports New called with no filesystem.
It is caught at construction because the alternative is a nil dereference on the first client request — long after the mistake, in a per-session goroutine, with no recover, taking down every other tenant the process is serving.
var ErrNoInit = errors.New("sftp: first packet was not SSH_FXP_INIT")
ErrNoInit reports a peer whose first packet was not SSH_FXP_INIT.
The session cannot continue: version negotiation is the only thing that establishes what the following bytes mean, so guessing would be worse than stopping.
var ErrVersion = errors.New("sftp: unsupported client protocol version")
ErrVersion reports a peer demanding a protocol version this server cannot speak. See wire.Version.
Functions ¶
This section is empty.
Types ¶
type Option ¶
type Option func(*Server)
Option configures a Server.
func ReadWrite ¶
func ReadWrite() Option
ReadWrite makes the export writable.
Exports are READ-ONLY BY DEFAULT, for two reasons that both point the same way. Most of what this module is pointed at is a forensic or build artefact, and an accidental write to one is unrecoverable. And until a driver's File implements WritableFile, every write at a non-zero offset is a read-modify-write of the entire file — see the package documentation and the measurement in the README. Opting in should be a decision, not a default.
func WithMaxPacket ¶
WithMaxPacket caps the size of a single SFTP packet, in bytes, counting the type byte and payload.
The default, wire.MaxPacket, accepts everything OpenSSH's client sends including its largest -B setting. Lowering it is how a host running many tenants bounds the memory any one session can make it hold. A non-positive value restores the default.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server exports one github.com/go-filesystems/interface.Filesystem over SFTP version 3.
One Server holds exactly one filesystem and no other capability. It has no listener, no goroutine and no global state until a session is handed to Server.Serve, which is what makes running one per tenant — the shape this module is built for — cheap rather than aspirational.
A Server is safe for concurrent use by any number of sessions.
func New ¶
func New(fsys filesystem.Filesystem, opts ...Option) (*Server, error)
New returns a Server exporting fsys, read-only unless ReadWrite is given.
func (*Server) Serve ¶
func (s *Server) Serve(rw io.ReadWriter) error
Serve runs one SFTP session over rw until the peer closes it or the stream fails, and returns nil on a clean close.
rw is one client's SFTP channel: in the ordinary case the SSH subsystem channel opened by github.com/go-filesystems/sftp/sshd, but deliberately only an io.ReadWriter, so a program that already runs an SSH server can hand this its own channel, and a test can hand it a pipe. Serve does not close rw — it did not open it.
Every handle the session opened is released before Serve returns, including when the client vanishes mid-transfer, which is what killing an `sftp` process does.
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 file this server exports currently carries the server's start time in atime and mtime. That is visibly wrong in a client's listing and is reported here rather than hidden: the fix is a timestamp accessor on interface.Stat, not a plausible guess in this module. The probe exists so the day a driver reports one, the listing gets it without a change here.
type WritableFile ¶
type WritableFile = filesystem.WritableFile
WritableFile is the capability that removes this module's write wall.
It is an open file that can be written AT AN OFFSET, which is exactly what SSH_FXP_WRITE(handle, offset, data) asks for, and exactly what the base github.com/go-filesystems/interface.Filesystem contract cannot express: that contract's only write is WriteFile, which replaces a file WHOLE. So serving a 32 KiB write into the middle of a 100 MiB file without this capability means reading 100 MiB, splicing 32 KiB in, and writing 100 MiB back — per request. See [writeSplice] and the README for the measurement of both paths.
It is an alias for github.com/go-filesystems/interface.WritableFile, published in interface v0.3.0, rather than a local redeclaration. The alias exists purely so that this package's own documentation has somewhere to explain what the capability means TO AN SFTP SERVER — which is not the same question as what it means to the interface module — while the type itself stays the fleet's one canonical definition. A driver satisfies it or does not; there is no second contract here to drift out of sync.
This module never implements it. It probes for it on the File returned by github.com/go-filesystems/interface.Opener, uses it when present, and falls back to read-modify-write with a documented cost when absent.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package sshd is the SSH front-end for github.com/go-filesystems/sftp: it authenticates clients with public keys, accepts the "sftp" subsystem, and hands the resulting channel to the SFTP server.
|
Package sshd is the SSH front-end for github.com/go-filesystems/sftp: it authenticates clients with public keys, accepts the "sftp" subsystem, and hands the resulting channel to the SFTP server. |
|
Package wire is the SFTP version 3 codec: packet framing, message types, and the SSH binary encoding they are built from.
|
Package wire is the SFTP version 3 codec: packet framing, message types, and the SSH binary encoding they are built from. |
