End-to-end testing SFTP/FTPS clients without Docker (Go)
I recently added a feature to a CLI tool that uploads build output to a server. Two transfer methods: SFTP (over SSH) and FTPS (FTP with TLS). What I wanted to be sure worked was the whole chain — connect, authenticate, create directories, transfer files — so unit tests on individual functions weren’t enough. I wanted a real end-to-end test that actually connects to a server and does a round trip.
The obvious move is to stand up vsftpd or sftpgo in Docker. But a few things made me reluctant before I’d even started:
- Running the tests would require a Docker daemon (locally and in CI).
- FTP’s passive mode opens a separate port for data transfer. That plays badly with container port mapping, and the setup balloons fast.
- It just felt like a lot of moving infrastructure for one test.
Here’s the conclusion up front: by standing the server up inside the Go test process (in-process), I could E2E-test both methods without Docker — and for one of them, without even adding a new dependency. This post is how, plus the deadlock I stepped on along the way.
Docker vs. in-process
| Docker (vsftpd etc.) | in-process Go server | |
|---|---|---|
| Daemon | required | none |
| Startup | wait for the container | instant, in-process |
| FTP passive ports | mapping setup needed | painless on localhost |
| Implementation effort | write a compose file | a few dozen lines |
| Real-server quirks | reproducible | not reproducible (see below) |
That last row is the one real weakness, and I’ll come back to it at the end. Everything else favored in-process.
SFTP: stand up an SSH server and let pkg/sftp talk
SFTP is a subsystem that runs over SSH. In Go, the very libraries you use on the client side — golang.org/x/crypto/ssh and github.com/pkg/sftp — can serve the server side too. So no new test dependency.
We listen on 127.0.0.1:0 (port 0 = let the OS pick a free one) and accept password auth only.
// Start a test SSH server and return its listen address.
func newSSHServer(t *testing.T, user, pass string) string {
config := &ssh.ServerConfig{
PasswordCallback: func(c ssh.ConnMetadata, p []byte) (*ssh.Permissions, error) {
if c.User() == user && string(p) == pass {
return nil, nil
}
return nil, fmt.Errorf("auth failed")
},
}
config.AddHostKey(testHostKey(t)) // generate an ed25519 key on the spot
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go serve(conn, config)
}
}()
t.Cleanup(func() { _ = ln.Close() })
return ln.Addr().String()
}
On a connection, we finish the SSH handshake, say “OK” only to the sftp subsystem request on the session channel, and let pkg/sftp’s server speak to the filesystem.
func serve(conn net.Conn, config *ssh.ServerConfig) {
sconn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil {
return
}
defer sconn.Close()
go ssh.DiscardRequests(reqs)
for ch := range chans {
if ch.ChannelType() != "session" {
_ = ch.Reject(ssh.UnknownChannelType, "")
continue
}
channel, requests, _ := ch.Accept()
go func() {
for req := range requests {
_ = req.Reply(req.Type == "subsystem", nil) // OK the sftp request
}
}()
server, _ := sftp.NewServer(channel)
go func() {
_ = server.Serve() // blocks until the client disconnects
_ = channel.Close() // ★ without this, the client hangs (see below)
}()
}
}
sftp.NewServer serves the real filesystem, so you can point the remote upload target at an absolute t.TempDir() path and verify the transferred files right there on disk.
The test itself drives the production client (here, sftpDeployer) whole. ssh.Dial → password auth → upload all run through the same path as production.
d := &sftpDeployer{
addr: addr,
sshConfig: &ssh.ClientConfig{
User: "deploy",
Auth: []ssh.AuthMethod{ssh.Password("secret")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // test only; verify in production
},
remoteDir: t.TempDir(),
}
if _, err := d.Deploy(srcDir, func(string) {}); err != nil {
t.Fatal(err)
}
// assert the srcDir tree was reproduced under remoteDir
I skipped host-key verification in the test with ssh.InsecureIgnoreHostKey(). The key-pinning logic is easier to cover separately, as a pure function.
The snag: the upload succeeds, but the test hangs
The first time I wrote this, the test failed on a 60-second timeout. A deadlock. Reading the goroutine dump that go test -timeout prints revealed something interesting.
The upload itself had succeeded. What hung was the cleanup — sftp.Client.Close(), called from t.Cleanup.
Close() waits, via a WaitGroup, for its internal receive goroutine (the one that keeps reading the server’s responses) to finish. But that receive goroutine never got EOF from the channel, so it kept reading forever.
The cause was on the pkg/sftp server side. Server.Serve() does not close the connection (io.ReadWriteCloser) it was handed — because the design says the caller owns the connection. My first version just called server.Serve() and never closed the channel when it returned. The result:
| Who’s waiting | On what | Released when |
|---|---|---|
client’s Close() |
its own receive goroutine | the receiver gets EOF |
| receive goroutine | the server closing the channel | the server closes after Serve() |
server’s Serve() |
the client disconnecting | the client’s Close() completes |
Three parties in a ring, each waiting on the next. The client waits for the server to disconnect; the server waits for the client to disconnect — a classic deadlock.
The fix is one line. On the server side, close the channel right after Serve() returns.
go func() {
_ = server.Serve()
_ = channel.Close() // the receiver gets EOF, and the client's Close() returns
}()
To my embarrassment, I first suspected the wiring (how the pipes were connected) and swapped io.Pipe for os.Pipe. It changed nothing. Once I calmed down and read pkg/sftp’s own tests, I saw they use the same io.Pipe wiring and work fine — the only difference was the teardown discipline. The problem wasn’t the wiring; it was that the server never closed the connection after the transfer. Misread which side owns the “close the connection” responsibility, and you get exactly this quiet hang.
FTPS: stand up ftpserverlib, mint the cert on the fly
There’s no standard-library FTPS server, so for testing I reached for github.com/fclairamb/ftpserverlib. Implement the driver interface minimally and it runs in-process.
type driver struct {
user, pass string
fs afero.Fs
tls *tls.Config
}
func (d *driver) GetSettings() (*ftpserver.Settings, error) {
return &ftpserver.Settings{
ListenAddr: "127.0.0.1:0",
TLSRequired: ftpserver.ClearOrEncrypted, // allow the AUTH TLS upgrade
// leaving PassiveTransferPortRange nil uses an ephemeral port
}, nil
}
func (d *driver) GetTLSConfig() (*tls.Config, error) { return d.tls, nil }
func (d *driver) AuthUser(_ ftpserver.ClientContext, u, p string) (ftpserver.ClientDriver, error) {
if u == d.user && p == d.pass {
return d.fs, nil // ClientDriver is just an afero.Fs
}
return nil, errors.New("bad credentials")
}
func (d *driver) ClientConnected(ftpserver.ClientContext) (string, error) { return "ok", nil }
func (d *driver) ClientDisconnected(ftpserver.ClientContext) {}
Two things matter here.
First, the certificate. Mint a fresh self-signed ed25519 cert per test and hand it to the server; the client accepts it with InsecureSkipVerify (the self-signed verification path is covered by a separate test). No fixed cert file to keep in the repo.
Second, the passive-mode port I griped about earlier. Leave PassiveTransferPortRange as nil and ftpserverlib uses a fresh OS port (ephemeral) per transfer. And the client (github.com/jlaffaye/ftp) uses EPSV, which reuses the control connection’s IP for the data connection. The two line up, so on localhost there’s no port configuration at all. The thing that was most painful in Docker is simply nothing in-process — that was the clincher.
For the filesystem, rooting an afero.NewBasePathFs(afero.NewOsFs(), t.TempDir()) at a temp dir lets you verify the transfer result directly.
Keep test-only libraries out of the binary
The FTPS server library is only used in tests. As long as it’s imported solely from *_test.go it won’t end up in the shipped binary, but it’s worth a quick check.
# list deps of the non-test build; if the server lib doesn't appear, it's not shipped
go list -deps . | grep -E 'ftpserverlib|afero'
No output means you’re good.
What in-process can’t reach
What an in-process server guarantees is that the client and server communicate correctly at the protocol level — and no further. It can’t go as far as verifying the quirks of real-world server implementations (vsftpd, ProFTPD, and so on), or things like TLS session resumption on the FTPS data connection.
So while an in-process server makes the test setup lightweight, what it verifies stops at the boundary — the point where the two sides have to line up. It can’t reach real-server behavior, so a manual test is ultimately still needed. Day-to-day regressions run lightly in-process, and the check against a real host happens once, at the end.
If you’ve been stuck on “how do I test this file-transfer client,” I hope this saves you a detour. Especially that deadlock — knowing up front who’s responsible for closing the connection is a lot less painful than finding out after it hangs.