mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 15:49:49 +00:00
588 lines
15 KiB
Go
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
|
|
}
|