Files

588 lines
15 KiB
Go

package ftp
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"errors"
"io"
"math/big"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
ftpserver "github.com/fclairamb/ftpserverlib"
"github.com/spf13/afero"
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
)
// ServerOptions are the knobs of the in-process FTP test server.
type ServerOptions struct {
// TLS is pftp.TLSNone (or empty), pftp.TLSExplicit or pftp.TLSImplicit.
// Explicit mode refuses plain logins, so a passing test proves TLS was used.
TLS string
// MaxLogins caps concurrent control connections. Beyond it the server
// greets with "421 Too many connections" and hangs up. Zero means no cap.
MaxLogins int
// NoRest answers "502" to REST. It filters the plain control stream, so it
// works with TLSNone and TLSImplicit only.
NoRest bool
// NoMLST disables MLST and MLSD, so clients fall back to SIZE and LIST.
NoMLST bool
// User and Pass are the only accepted credentials. An empty User accepts
// any login, anonymous included.
User string
Pass string
// RefuseDelay holds every 421 refusal back this long, like a server
// that answers slowly when it is full.
RefuseDelay time.Duration
// RequireSessionReuse makes every data connection resume the TLS
// session of the control connection that opened it, as ProFTPD's
// mod_tls does by default. A data connection that does not is refused.
// It needs TLSImplicit, where this test's wrapper sees the plain
// control stream and so learns which passive port belongs to whom.
RequireSessionReuse bool
}
// FTPServer is an ftpserverlib server over a temp directory.
type FTPServer struct {
Addr string // host:port
Root string // directory served as "/"
// CA trusts the server's self-signed certificate.
CA *x509.CertPool
opts ServerOptions
tlsConf *tls.Config
server *ftpserver.FtpServer
// ReadDelay is slept before every read of a transferred file, in
// nanoseconds, to slow downloads down. Tests may change it at any time.
ReadDelay atomic.Int64
// DropAfter, when positive, makes the next transfer fail once after it
// has sent that many bytes, as if the data connection were killed.
DropAfter atomic.Int64
mu sync.Mutex
active int
retrs []int64 // offset of every RETR, 0 when no REST came first
refused int
logins int
// Passive port to the control connection that announced it, and the
// outcome of every data connection's session check.
pasvOwner map[int]string
reuseOK int
reuseBad int
// Stall state, see StallAfter. stallMu is held across file reads.
stallMu sync.Mutex
stallAt int64
sent int64
stallCh chan struct{}
stalled chan struct{}
stalledOnce *sync.Once
}
// StartFTPServer starts a server and stops it when the test ends.
func StartFTPServer(t *testing.T, opts ServerOptions) *FTPServer {
t.Helper()
s := &FTPServer{Root: t.TempDir(), opts: opts}
s.tlsConf, s.CA = selfSignedTLS(t)
base, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
var ln net.Listener = base
if opts.RequireSessionReuse {
if opts.TLS != pftp.TLSImplicit {
t.Fatal("RequireSessionReuse needs TLSImplicit")
}
s.requireSessionReuse(t, base.Addr().(*net.TCPAddr).Port)
}
if opts.TLS == pftp.TLSImplicit {
ln = tls.NewListener(ln, s.tlsConf)
}
ln = &limitListener{Listener: ln, srv: s}
s.Addr = base.Addr().String()
s.server = ftpserver.NewFtpServer(&mainDriver{srv: s, listener: ln})
if err := s.server.Listen(); err != nil {
t.Fatal(err)
}
go s.server.Serve()
t.Cleanup(func() { _ = s.server.Stop() })
return s
}
// WriteFile creates a file under the served root and returns its SHA-256.
func (s *FTPServer) WriteFile(t *testing.T, rel string, data []byte) string {
t.Helper()
name := filepath.Join(s.Root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, data, 0o644); err != nil {
t.Fatal(err)
}
return SHA256Hex(data)
}
// RetrOffsets returns the start offset of every RETR the server received.
func (s *FTPServer) RetrOffsets() []int64 {
s.mu.Lock()
defer s.mu.Unlock()
return append([]int64(nil), s.retrs...)
}
// RestOffsets returns the non-zero RETR offsets, which only a REST sets.
func (s *FTPServer) RestOffsets() []int64 {
var out []int64
for _, off := range s.RetrOffsets() {
if off != 0 {
out = append(out, off)
}
}
return out
}
// Refused returns how many connections were turned away with 421.
func (s *FTPServer) Refused() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.refused
}
// Logins returns the number of successful logins.
func (s *FTPServer) Logins() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.logins
}
// SetMaxLogins changes the login cap while the server runs, as when other
// clients take slots.
func (s *FTPServer) SetMaxLogins(n int) {
s.mu.Lock()
s.opts.MaxLogins = n
s.mu.Unlock()
}
// SessionReuse returns how many data connections resumed their own control
// connection's TLS session, and how many did not.
func (s *FTPServer) SessionReuse() (ok, bad int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.reuseOK, s.reuseBad
}
// StallAfter makes every transfer stop sending once the server has sent n
// bytes in total, until Release. Stalled is closed when that happens.
func (s *FTPServer) StallAfter(n int64) {
s.stallMu.Lock()
defer s.stallMu.Unlock()
s.stallAt, s.sent = n, 0
s.stallCh = make(chan struct{})
s.stalled = make(chan struct{})
s.stalledOnce = &sync.Once{}
}
// Stalled is closed once a transfer has stopped at the StallAfter limit.
func (s *FTPServer) Stalled() <-chan struct{} {
s.stallMu.Lock()
defer s.stallMu.Unlock()
return s.stalled
}
// Sent returns the bytes sent since StallAfter.
func (s *FTPServer) Sent() int64 {
s.stallMu.Lock()
defer s.stallMu.Unlock()
return s.sent
}
// Release ends the stall. The stalled transfers fail.
func (s *FTPServer) Release() {
s.stallMu.Lock()
defer s.stallMu.Unlock()
if s.stallAt > 0 {
s.stallAt = 0
close(s.stallCh)
}
}
var errStalled = errors.New("test server released a stalled transfer")
// stall limits a read to the StallAfter budget, and blocks once the budget
// is spent.
func (s *FTPServer) stall(read func([]byte) (int, error), p []byte) (int, error) {
s.stallMu.Lock()
if s.stallAt <= 0 {
s.stallMu.Unlock()
return read(p)
}
allowed := s.stallAt - s.sent
if allowed <= 0 {
ch := s.stallCh
s.stalledOnce.Do(func() { close(s.stalled) })
s.stallMu.Unlock()
<-ch
return 0, errStalled
}
if int64(len(p)) > allowed {
p = p[:allowed]
}
n, err := read(p)
s.sent += int64(n)
s.stallMu.Unlock()
return n, err
}
var pasvReply = regexp.MustCompile(`^(?:229 [^(]*\(\|\|\|(\d+)\|\)|227 [^(]*\(\d+,\d+,\d+,\d+,(\d+),(\d+)\))`)
// notePassive records the passive port a control connection announced.
func (s *FTPServer) notePassive(owner string, reply []byte) {
m := pasvReply.FindSubmatch(reply)
if m == nil {
return
}
var port int
if len(m[1]) > 0 {
port, _ = strconv.Atoi(string(m[1]))
} else {
hi, _ := strconv.Atoi(string(m[2]))
lo, _ := strconv.Atoi(string(m[3]))
port = hi<<8 | lo
}
s.mu.Lock()
s.pasvOwner[port] = owner
s.mu.Unlock()
}
// requireSessionReuse stamps each control connection's session tickets with
// its address, and checks that each data connection resumed a ticket of the
// control connection that announced its passive port.
func (s *FTPServer) requireSessionReuse(t *testing.T, controlPort int) {
base := s.tlsConf
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
t.Fatal(err)
}
base.SetSessionTicketKeys([][32]byte{key})
s.pasvOwner = map[int]string{}
stamp := func(owner string) func(tls.ConnectionState, *tls.SessionState) ([]byte, error) {
return func(cs tls.ConnectionState, ss *tls.SessionState) ([]byte, error) {
ss.Extra = [][]byte{[]byte(owner)}
return base.EncryptTicket(cs, ss)
}
}
base.GetConfigForClient = func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
cfg := base.Clone()
cfg.GetConfigForClient = nil
local, _ := hello.Conn.LocalAddr().(*net.TCPAddr)
if local == nil || local.Port == controlPort {
cfg.WrapSession = stamp(hello.Conn.RemoteAddr().String())
return cfg, nil
}
s.mu.Lock()
owner := s.pasvOwner[local.Port]
s.mu.Unlock()
var resumedFrom string
cfg.UnwrapSession = func(identity []byte, cs tls.ConnectionState) (*tls.SessionState, error) {
ss, err := base.DecryptTicket(identity, cs)
if err != nil || ss == nil {
return nil, nil
}
if len(ss.Extra) > 0 {
resumedFrom = string(ss.Extra[0])
}
return ss, nil
}
cfg.WrapSession = stamp(owner)
cfg.VerifyConnection = func(cs tls.ConnectionState) error {
ok := owner != "" && cs.DidResume && resumedFrom == owner
s.mu.Lock()
if ok {
s.reuseOK++
} else {
s.reuseBad++
}
s.mu.Unlock()
if !ok {
return errors.New("data connection did not resume its control connection's TLS session")
}
return nil
}
return cfg, nil
}
}
func (s *FTPServer) acquire() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.opts.MaxLogins > 0 && s.active >= s.opts.MaxLogins {
s.refused++
return false
}
s.active++
return true
}
func (s *FTPServer) release() {
s.mu.Lock()
s.active--
s.mu.Unlock()
}
// limitListener counts control connections and refuses those beyond
// MaxLogins with a 421 greeting, like vsftpd's max_clients and max_per_ip.
type limitListener struct {
net.Listener
srv *FTPServer
}
func (l *limitListener) Accept() (net.Conn, error) {
for {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
if !l.srv.acquire() {
go func() {
time.Sleep(l.srv.opts.RefuseDelay)
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
_, _ = io.WriteString(c, "421 Too many connections\r\n")
_ = c.Close()
}()
continue
}
cc := &countedConn{Conn: c, release: l.srv.release}
if l.srv.opts.RequireSessionReuse {
owner := c.RemoteAddr().String()
cc.sniff = func(b []byte) { l.srv.notePassive(owner, b) }
}
var conn net.Conn = cc
if l.srv.opts.NoRest {
conn = &noRestConn{countedConn: conn.(*countedConn), in: bufio.NewReader(c)}
}
return conn, nil
}
}
type countedConn struct {
net.Conn
once sync.Once
release func()
sniff func([]byte) // sees every reply the server writes
}
func (c *countedConn) Write(b []byte) (int, error) {
if c.sniff != nil {
c.sniff(b)
}
return c.Conn.Write(b)
}
func (c *countedConn) Close() error {
c.once.Do(c.release)
return c.Conn.Close()
}
// noRestConn answers REST itself with 502 and hides it from the server.
type noRestConn struct {
*countedConn
in *bufio.Reader
pending []byte
}
func (c *noRestConn) Read(p []byte) (int, error) {
for len(c.pending) == 0 {
line, err := c.in.ReadBytes('\n')
if len(line) > 0 {
if strings.HasPrefix(strings.ToUpper(string(line)), "REST ") {
if _, werr := io.WriteString(c.Conn, "502 REST not implemented\r\n"); werr != nil {
return 0, werr
}
} else {
c.pending = line
}
}
if err != nil {
if len(c.pending) > 0 {
break
}
return 0, err
}
}
n := copy(p, c.pending)
c.pending = c.pending[n:]
return n, nil
}
type mainDriver struct {
srv *FTPServer
listener net.Listener
}
func (d *mainDriver) GetSettings() (*ftpserver.Settings, error) {
settings := &ftpserver.Settings{
Listener: d.listener,
DisableMLSD: d.srv.opts.NoMLST,
DisableMLST: d.srv.opts.NoMLST,
}
switch d.srv.opts.TLS {
case pftp.TLSExplicit:
settings.TLSRequired = ftpserver.MandatoryEncryption
case pftp.TLSImplicit:
settings.TLSRequired = ftpserver.ImplicitEncryption
}
return settings, nil
}
func (d *mainDriver) ClientConnected(ftpserver.ClientContext) (string, error) {
return "gopeed test server", nil
}
func (d *mainDriver) ClientDisconnected(ftpserver.ClientContext) {}
func (d *mainDriver) AuthUser(_ ftpserver.ClientContext, user, pass string) (ftpserver.ClientDriver, error) {
if d.srv.opts.User != "" && (user != d.srv.opts.User || pass != d.srv.opts.Pass) {
return nil, errors.New("bad credentials")
}
d.srv.mu.Lock()
d.srv.logins++
d.srv.mu.Unlock()
return &clientDriver{Fs: afero.NewBasePathFs(afero.NewOsFs(), d.srv.Root), srv: d.srv}, nil
}
func (d *mainDriver) GetTLSConfig() (*tls.Config, error) {
return d.srv.tlsConf, nil
}
type clientDriver struct {
afero.Fs
srv *FTPServer
}
// GetHandle sees the REST offset of every transfer. The server seeks the
// returned file to that offset itself.
func (d *clientDriver) GetHandle(name string, flags int, offset int64) (ftpserver.FileTransfer, error) {
f, err := d.Fs.OpenFile(name, flags, 0o644)
if err != nil {
return nil, err
}
if flags == os.O_RDONLY {
d.srv.mu.Lock()
d.srv.retrs = append(d.srv.retrs, offset)
d.srv.mu.Unlock()
}
return &slowFile{File: f, srv: d.srv, dropAfter: d.srv.DropAfter.Swap(0)}, nil
}
var errDropped = errors.New("test server dropped the transfer")
type slowFile struct {
afero.File
srv *FTPServer
sent int64
dropAfter int64
}
func (f *slowFile) Read(p []byte) (int, error) {
if delay := time.Duration(f.srv.ReadDelay.Load()); delay > 0 {
time.Sleep(delay)
if len(p) > 16<<10 {
p = p[:16<<10]
}
}
if f.dropAfter > 0 {
if f.sent >= f.dropAfter {
return 0, errDropped
}
if rest := f.dropAfter - f.sent; int64(len(p)) > rest {
p = p[:rest]
}
}
n, err := f.srv.stall(f.File.Read, p)
f.sent += int64(n)
return n, err
}
// selfSignedTLS returns a server config with a fresh certificate for
// 127.0.0.1 and localhost, and a pool that trusts it.
func selfSignedTLS(t *testing.T) (*tls.Config, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: "gopeed ftp test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IsCA: true,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
pool := x509.NewCertPool()
pool.AddCert(cert)
return &tls.Config{
Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key, Leaf: cert}},
MinVersion: tls.VersionTLS12,
}, pool
}
func SHA256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func FileSHA256(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return SHA256Hex(data)
}
// RandomBytes returns n reproducible pseudo-random bytes.
func RandomBytes(n int, seed byte) []byte {
out := make([]byte, n)
x := uint32(seed) + 1
for i := range out {
x ^= x << 13
x ^= x >> 17
x ^= x << 5
out[i] = byte(x)
}
return out
}