mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 08:30:29 +00:00
405 lines
12 KiB
Go
405 lines
12 KiB
Go
package ftp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/textproto"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jlaffaye/ftp"
|
|
"golang.org/x/net/proxy"
|
|
|
|
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
|
)
|
|
|
|
const (
|
|
connectTimeout = 15 * time.Second
|
|
readTimeout = 15 * time.Second
|
|
quitTimeout = 2 * time.Second
|
|
)
|
|
|
|
// target is a parsed FTP URL. It holds the password, so it must never be
|
|
// formatted into an error or a log line.
|
|
type target struct {
|
|
host string // host name or IP, the TLS server name
|
|
addr string // host:port
|
|
user string
|
|
pass string
|
|
path string // path relative to the login directory, "" for the directory itself
|
|
tlsMode string
|
|
}
|
|
|
|
// parseTarget reads an ftp, ftps or ftpes URL. The path follows RFC 1738: it
|
|
// is relative to the login directory, and an encoded leading slash
|
|
// (ftp://host/%2Fpub) makes it absolute.
|
|
func parseTarget(rawURL, optTLS string) (*target, error) {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
// url.Error repeats the whole URL, password included.
|
|
return nil, errors.New("ftp: invalid URL")
|
|
}
|
|
t := &target{}
|
|
switch strings.ToLower(u.Scheme) {
|
|
case "ftps":
|
|
t.tlsMode = pftp.TLSImplicit
|
|
case "ftpes":
|
|
t.tlsMode = pftp.TLSExplicit
|
|
case "ftp":
|
|
switch strings.ToLower(optTLS) {
|
|
case "", pftp.TLSNone:
|
|
t.tlsMode = pftp.TLSNone
|
|
case pftp.TLSExplicit:
|
|
t.tlsMode = pftp.TLSExplicit
|
|
case pftp.TLSImplicit:
|
|
t.tlsMode = pftp.TLSImplicit
|
|
default:
|
|
return nil, fmt.Errorf("ftp: unknown tls mode %q", optTLS)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("ftp: unsupported scheme %q", u.Scheme)
|
|
}
|
|
t.host = u.Hostname()
|
|
if t.host == "" {
|
|
return nil, errors.New("ftp: URL has no host")
|
|
}
|
|
port := u.Port()
|
|
if port == "" {
|
|
port = "21"
|
|
if t.tlsMode == pftp.TLSImplicit {
|
|
port = "990"
|
|
}
|
|
}
|
|
t.addr = net.JoinHostPort(t.host, port)
|
|
if u.User != nil {
|
|
// Username and Password are already percent-decoded.
|
|
t.user = u.User.Username()
|
|
t.pass, _ = u.User.Password()
|
|
}
|
|
if t.user == "" {
|
|
t.user, t.pass = "anonymous", "anonymous@"
|
|
}
|
|
t.path = strings.TrimPrefix(u.Path, "/")
|
|
return t, nil
|
|
}
|
|
|
|
type dialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
|
|
// session is one logged-in control connection. It owns the network
|
|
// connections it opens, so abort can unblock any read or write in progress.
|
|
type session struct {
|
|
*ftp.ServerConn
|
|
|
|
ctrl net.Conn // control connection as dialed, under any TLS layer
|
|
dataHost string // where data connections go
|
|
|
|
mu sync.Mutex
|
|
data net.Conn // current data connection as dialed
|
|
closed bool
|
|
stop func() bool
|
|
}
|
|
|
|
// deadline bounds the next control-connection exchange. The control
|
|
// connection is idle during a transfer, so every exchange sets a new one.
|
|
func (s *session) deadline() {
|
|
_ = s.ctrl.SetDeadline(time.Now().Add(readTimeout))
|
|
}
|
|
|
|
// clearDeadline lets the control connection stay idle through a transfer.
|
|
func (s *session) clearDeadline() {
|
|
_ = s.ctrl.SetDeadline(time.Time{})
|
|
}
|
|
|
|
// abort closes every connection at once. Pending calls fail immediately.
|
|
func (s *session) abort() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.closed = true
|
|
_ = s.ctrl.Close()
|
|
if s.data != nil {
|
|
_ = s.data.Close()
|
|
}
|
|
}
|
|
|
|
// quit logs out and waits, briefly, for the server to hang up. Servers that
|
|
// cap logins count a session until they have seen it end, so the next login
|
|
// would otherwise be refused.
|
|
func (s *session) quit() {
|
|
if s.stop != nil {
|
|
s.stop()
|
|
}
|
|
_ = s.ctrl.SetDeadline(time.Now().Add(quitTimeout))
|
|
if s.ServerConn != nil {
|
|
_ = s.ServerConn.Quit()
|
|
}
|
|
s.abort()
|
|
}
|
|
|
|
func (s *session) setData(c net.Conn) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
_ = c.Close()
|
|
return net.ErrClosed
|
|
}
|
|
s.data = c
|
|
return nil
|
|
}
|
|
|
|
// ctrlConn is the control connection handed to the ftp library.
|
|
type ctrlConn struct {
|
|
net.Conn
|
|
}
|
|
|
|
// RemoteAddr must be a *net.TCPAddr: the library asserts it. A connection
|
|
// through a proxy may report something else, and its address is not used
|
|
// for data connections anyway (see dialData).
|
|
func (c ctrlConn) RemoteAddr() net.Addr {
|
|
if a, ok := c.Conn.RemoteAddr().(*net.TCPAddr); ok {
|
|
return a
|
|
}
|
|
return &net.TCPAddr{}
|
|
}
|
|
|
|
// Close is called by the library's Quit right after it sends QUIT. Reading
|
|
// until the server hangs up (bounded by the deadline quit set) tells us the
|
|
// server has ended the session.
|
|
func (c ctrlConn) Close() error {
|
|
_, _ = io.Copy(io.Discard, c.Conn)
|
|
return c.Conn.Close()
|
|
}
|
|
|
|
// dial opens a control connection, upgrades it to TLS when asked, and logs in.
|
|
//
|
|
// The library is given a dial function for every connection. That is how
|
|
// data connections go through the same proxy as the control connection, but
|
|
// it also means the library does no TLS on them: with a dial function it
|
|
// neither wraps an implicit-TLS control connection nor any data connection.
|
|
// We do both here. Explicit TLS (AUTH TLS) is still done by the library, and
|
|
// so are PBSZ and PROT P after login. For the same reason the library's
|
|
// DialWithTimeout and DialWithContext would have no effect: connectTimeout
|
|
// and ctx are applied to our own dials instead.
|
|
func (f *Fetcher) dial(ctx context.Context, t *target) (*session, error) {
|
|
netDial, proxied, err := f.netDialer(t)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tlsConf *tls.Config
|
|
if t.tlsMode != pftp.TLSNone {
|
|
tlsConf = f.tlsConfig(t.host)
|
|
}
|
|
|
|
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
|
|
defer cancel()
|
|
raw, err := netDial(dialCtx, "tcp", t.addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctrl := raw
|
|
if t.tlsMode == pftp.TLSImplicit {
|
|
tc := tls.Client(raw, tlsConf)
|
|
if err := tc.HandshakeContext(dialCtx); err != nil {
|
|
_ = raw.Close()
|
|
return nil, err
|
|
}
|
|
ctrl = tc
|
|
}
|
|
|
|
s := &session{ctrl: raw, dataHost: t.host}
|
|
if a, ok := raw.RemoteAddr().(*net.TCPAddr); ok && !proxied {
|
|
// Same IP as the control connection, as the library itself does,
|
|
// so a name with several addresses cannot send us elsewhere.
|
|
s.dataHost = a.IP.String()
|
|
}
|
|
s.stop = context.AfterFunc(ctx, s.abort)
|
|
s.deadline()
|
|
|
|
handedOut := false
|
|
opts := []ftp.DialOption{
|
|
ftp.DialWithShutTimeout(readTimeout),
|
|
ftp.DialWithDialFunc(func(network, addr string) (net.Conn, error) {
|
|
if !handedOut {
|
|
// Dial asks for the control connection first, once.
|
|
handedOut = true
|
|
return ctrlConn{ctrl}, nil
|
|
}
|
|
return s.dialData(ctx, netDial, tlsConf, addr)
|
|
}),
|
|
}
|
|
switch t.tlsMode {
|
|
case pftp.TLSImplicit:
|
|
opts = append(opts, ftp.DialWithTLS(tlsConf))
|
|
case pftp.TLSExplicit:
|
|
opts = append(opts, ftp.DialWithExplicitTLS(tlsConf))
|
|
}
|
|
c, err := ftp.Dial(t.addr, opts...)
|
|
if err != nil {
|
|
s.stop()
|
|
s.abort()
|
|
return nil, err
|
|
}
|
|
s.ServerConn = c
|
|
s.deadline()
|
|
if err := c.Login(t.user, t.pass); err != nil {
|
|
s.stop()
|
|
s.abort()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// dialData opens a passive data connection. The library takes the host from
|
|
// the control connection's peer, which through a proxy is the proxy, so only
|
|
// the port is used here.
|
|
func (s *session) dialData(ctx context.Context, netDial dialContextFunc, tlsConf *tls.Config, addr string) (net.Conn, error) {
|
|
_, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
|
|
defer cancel()
|
|
raw, err := netDial(dialCtx, "tcp", net.JoinHostPort(s.dataHost, port))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.setData(raw); err != nil {
|
|
return nil, err
|
|
}
|
|
if tlsConf != nil {
|
|
// The handshake runs on the first read, as in the library, because
|
|
// some servers only start it once the transfer command is answered.
|
|
return tls.Client(raw, tlsConf), nil
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// tlsConfig returns the TLS settings for one control connection and its data
|
|
// connections. Certificates are verified unless the request sets
|
|
// skipVerifyCert, as for HTTP.
|
|
//
|
|
// Each call gets its own one-entry session cache, so a data connection
|
|
// resumes the TLS session of its own control connection. vsftpd
|
|
// (require_ssl_reuse) only checks that some session was resumed, but ProFTPD's
|
|
// mod_tls also checks that it is the control connection's; a cache shared by
|
|
// several workers would hand a data connection another worker's session.
|
|
// A cache in the manager's base config is kept as given.
|
|
func (f *Fetcher) tlsConfig(host string) *tls.Config {
|
|
cfg := &tls.Config{}
|
|
if f.manager != nil && f.manager.baseTLS != nil {
|
|
cfg = f.manager.baseTLS.Clone()
|
|
}
|
|
cfg.ServerName = host
|
|
if f.meta.Req != nil && f.meta.Req.SkipVerifyCert {
|
|
cfg.InsecureSkipVerify = true
|
|
}
|
|
if cfg.ClientSessionCache == nil {
|
|
cfg.ClientSessionCache = tls.NewLRUClientSessionCache(1)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// netDialer picks how to reach the server, from the same proxy settings the
|
|
// HTTP protocol reads (the task's proxy, otherwise the global one).
|
|
func (f *Fetcher) netDialer(t *target) (dialContextFunc, bool, error) {
|
|
direct := (&net.Dialer{Timeout: connectTimeout}).DialContext
|
|
pu, err := f.proxyURL(t)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if pu == nil {
|
|
return direct, false, nil
|
|
}
|
|
switch strings.ToLower(pu.Scheme) {
|
|
case "socks5", "socks5h":
|
|
d, err := proxy.FromURL(pu, &net.Dialer{Timeout: connectTimeout})
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("ftp: socks5 proxy: %w", err)
|
|
}
|
|
if cd, ok := d.(proxy.ContextDialer); ok {
|
|
return cd.DialContext, true, nil
|
|
}
|
|
return func(_ context.Context, network, addr string) (net.Conn, error) {
|
|
return d.Dial(network, addr)
|
|
}, true, nil
|
|
default:
|
|
// An HTTP proxy is not used. Every passive data connection goes to
|
|
// a port the server picks, and HTTP proxies usually allow CONNECT to
|
|
// port 443 only, so the transfer would fail after the login worked.
|
|
f.warnOnce.Do(func() {
|
|
if l := f.logger(); l != nil {
|
|
l.Warn().Msgf("ftp: the %s proxy at %s cannot carry FTP data connections (HTTP proxy), connecting directly; use a SOCKS5 proxy for FTP", strings.ToLower(pu.Scheme), pu.Host)
|
|
}
|
|
})
|
|
return direct, false, nil
|
|
}
|
|
}
|
|
|
|
func (f *Fetcher) proxyURL(t *target) (*url.URL, error) {
|
|
if f.ctl == nil || f.ctl.GetProxy == nil {
|
|
return nil, nil
|
|
}
|
|
handler := f.ctl.GetProxy(f.meta.Req.Proxy)
|
|
if handler == nil {
|
|
return nil, nil
|
|
}
|
|
// The proxy chooser reads only the URL; the credentials stay out.
|
|
req := &http.Request{URL: &url.URL{Scheme: "ftp", Host: t.addr, Path: "/" + t.path}, Header: http.Header{}}
|
|
pu, err := handler(req)
|
|
if err != nil {
|
|
return nil, errors.New("ftp: cannot read the proxy settings")
|
|
}
|
|
return pu, nil
|
|
}
|
|
|
|
// isLoginRefusal tells a server that is full apart from bad credentials.
|
|
// 421 always means "not now". 530 means a refused login; it is only a
|
|
// capacity limit when these credentials have worked before. The library
|
|
// drops the code when USER itself is refused, so a "too many" message counts
|
|
// as well.
|
|
func isLoginRefusal(err error, credentialsWorked bool) bool {
|
|
var te *textproto.Error
|
|
if errors.As(err, &te) {
|
|
switch te.Code {
|
|
case ftp.StatusNotAvailable:
|
|
return true
|
|
case ftp.StatusNotLoggedIn:
|
|
return credentialsWorked || strings.Contains(strings.ToLower(te.Msg), "too many")
|
|
}
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "too many")
|
|
}
|
|
|
|
// isPermanent reports errors a retry cannot fix: a 5xx reply and a
|
|
// certificate that does not verify.
|
|
func isPermanent(err error) bool {
|
|
var te *textproto.Error
|
|
if errors.As(err, &te) {
|
|
return te.Code >= 500
|
|
}
|
|
var verifyErr *tls.CertificateVerificationError
|
|
if errors.As(err, &verifyErr) {
|
|
return true
|
|
}
|
|
var recordErr tls.RecordHeaderError
|
|
if errors.As(err, &recordErr) {
|
|
return true
|
|
}
|
|
return errors.Is(err, errSizeChanged)
|
|
}
|
|
|
|
func replyCode(err error) int {
|
|
var te *textproto.Error
|
|
if errors.As(err, &te) {
|
|
return te.Code
|
|
}
|
|
return 0
|
|
}
|