mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2024-04-21 12:32:08 +00:00
443 lines
11 KiB
Go
443 lines
11 KiB
Go
// Copyright 2015 Muir Manders. All rights reserved.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package goftp
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Error is an expanded error interface returned by all Client methods.
|
|
// It allows discerning callers to discover potentially actionable qualities
|
|
// of the error.
|
|
type Error interface {
|
|
error
|
|
|
|
// Whether the error was transient and attempting the same operation
|
|
// again may be succesful. This includes timeouts.
|
|
Temporary() bool
|
|
|
|
// If the error originated from an unexpected response from the server, this
|
|
// will return the FTP response code. Otherwise it will return 0.
|
|
Code() int
|
|
|
|
// Similarly, this will return the text response from the server, or empty
|
|
// string.
|
|
Message() string
|
|
}
|
|
|
|
type ftpError struct {
|
|
err error
|
|
code int
|
|
msg string
|
|
timeout bool
|
|
temporary bool
|
|
}
|
|
|
|
func (e ftpError) Error() string {
|
|
if e.err != nil {
|
|
return e.err.Error()
|
|
} else {
|
|
return fmt.Sprintf("unexpected response: %d-%s", e.code, e.msg)
|
|
}
|
|
}
|
|
|
|
func (e ftpError) Temporary() bool {
|
|
return e.temporary || transientNegativeCompletionReply(e.code)
|
|
}
|
|
|
|
func (e ftpError) Timeout() bool {
|
|
return e.timeout
|
|
}
|
|
|
|
func (e ftpError) Code() int {
|
|
if fe, _ := e.err.(Error); fe != nil {
|
|
return fe.Code()
|
|
}
|
|
return e.code
|
|
}
|
|
|
|
func (e ftpError) Message() string {
|
|
if fe, _ := e.err.(Error); fe != nil {
|
|
return fe.Message()
|
|
}
|
|
return e.msg
|
|
}
|
|
|
|
// TLSMode represents the FTPS connection strategy. Servers cannot support
|
|
// both modes on the same port.
|
|
type TLSMode int
|
|
|
|
const (
|
|
// TLSExplicit means the client first runs an explicit command ("AUTH TLS")
|
|
// before switching to TLS.
|
|
TLSExplicit TLSMode = 0
|
|
|
|
// TLSImplicit means both sides already implicitly agree to use TLS, and the
|
|
// client connects directly using TLS.
|
|
TLSImplicit TLSMode = 1
|
|
)
|
|
|
|
// for testing
|
|
type stubResponse struct {
|
|
code int
|
|
msg string
|
|
}
|
|
|
|
// Config contains configuration for a Client object.
|
|
type Config struct {
|
|
// User name. Defaults to "anonymous".
|
|
User string
|
|
|
|
// User password. Defaults to "anonymous" if required.
|
|
Password string
|
|
|
|
// Maximum number of FTP connections to open per-host. Defaults to 5. Keep in
|
|
// mind that FTP servers typically limit how many connections a single user
|
|
// may have open at once, so you may need to lower this if you are doing
|
|
// concurrent transfers.
|
|
ConnectionsPerHost int
|
|
|
|
// Timeout for opening connections, sending control commands, and each read/write
|
|
// of data transfers. Defaults to 5 seconds.
|
|
Timeout time.Duration
|
|
|
|
// TLS Config used for FTPS. If provided, it will be an error if the server
|
|
// does not support TLS. Both the control and data connection will use TLS.
|
|
TLSConfig *tls.Config
|
|
|
|
// FTPS mode. TLSExplicit means connect non-TLS, then upgrade connection to
|
|
// TLS via "AUTH TLS" command. TLSImplicit means open the connection using
|
|
// TLS. Defaults to TLSExplicit.
|
|
TLSMode TLSMode
|
|
|
|
// This flag controls whether to use IPv6 addresses found when resolving
|
|
// hostnames. Defaults to false to prevent failures when your computer can't
|
|
// IPv6. If the hostname(s) only resolve to IPv6 addresses, Dial() will still
|
|
// try to use them as a last ditch effort. You can still directly give an
|
|
// IPv6 address to Dial() even with this flag off.
|
|
IPv6Lookup bool
|
|
|
|
// Logging destination for debugging messages. Set to os.Stderr to log to stderr.
|
|
// Password value will not be logged.
|
|
Logger io.Writer
|
|
|
|
// Time zone of the FTP server. Used when parsing mtime from "LIST" output if
|
|
// server does not support "MLST"/"MLSD". Defaults to UTC.
|
|
ServerLocation *time.Location
|
|
|
|
// Enable "active" FTP data connections where the server connects to the client to
|
|
// establish data connections (does not work if client is behind NAT). If TLSConfig
|
|
// is specified, it will be used when listening for active connections.
|
|
ActiveTransfers bool
|
|
|
|
// Set the host:port to listen on for active data connections. If the host and/or
|
|
// port is empty, the local address/port of the control connection will be used. A
|
|
// port of 0 will listen on a random port. If not specified, the default behavior is
|
|
// ":0", i.e. listen on the local control connection host and a random port.
|
|
ActiveListenAddr string
|
|
|
|
// Disables EPSV in favour of PASV. This is useful in cases where EPSV connections
|
|
// neither complete nor downgrade to PASV successfully by themselves, resulting in
|
|
// hung connections.
|
|
DisableEPSV bool
|
|
|
|
// For testing convenience.
|
|
stubResponses map[string]stubResponse
|
|
}
|
|
|
|
// Client maintains a connection pool to the FTP server(s), so you typically only
|
|
// need one Client object. Client methods are safe to call concurrently from
|
|
// different goroutines, but once you are using all ConnectionsPerHost connections
|
|
// per host, methods will block waiting for a free connection.
|
|
type Client struct {
|
|
config Config
|
|
hosts []string
|
|
freeConnCh chan *persistentConn
|
|
numConnsPerHost map[string]int
|
|
allCons map[int]*persistentConn
|
|
connIdx int
|
|
rawConnIdx int
|
|
mu sync.Mutex
|
|
t0 time.Time
|
|
closed bool
|
|
}
|
|
|
|
// Construct and return a new client Conn, setting default config
|
|
// values as necessary.
|
|
func newClient(config Config, hosts []string) *Client {
|
|
|
|
if config.ConnectionsPerHost <= 0 {
|
|
config.ConnectionsPerHost = 5
|
|
}
|
|
|
|
if config.Timeout <= 0 {
|
|
config.Timeout = 5 * time.Second
|
|
}
|
|
|
|
if config.User == "" {
|
|
config.User = "anonymous"
|
|
}
|
|
|
|
if config.Password == "" {
|
|
config.Password = "anonymous"
|
|
}
|
|
|
|
if config.ServerLocation == nil {
|
|
config.ServerLocation = time.UTC
|
|
}
|
|
|
|
if config.ActiveListenAddr == "" {
|
|
config.ActiveListenAddr = ":0"
|
|
}
|
|
|
|
return &Client{
|
|
config: config,
|
|
freeConnCh: make(chan *persistentConn, len(hosts)*config.ConnectionsPerHost),
|
|
t0: time.Now(),
|
|
hosts: hosts,
|
|
allCons: make(map[int]*persistentConn),
|
|
numConnsPerHost: make(map[string]int),
|
|
}
|
|
}
|
|
|
|
// Close closes all open server connections. Currently this does not attempt
|
|
// to do any kind of polite FTP connection termination. It will interrupt
|
|
// all transfers in progress.
|
|
func (c *Client) Close() error {
|
|
c.mu.Lock()
|
|
if c.closed {
|
|
c.mu.Unlock()
|
|
return ftpError{err: errors.New("already closed")}
|
|
}
|
|
c.closed = true
|
|
|
|
var conns []*persistentConn
|
|
for _, conn := range c.allCons {
|
|
conns = append(conns, conn)
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
for _, pconn := range conns {
|
|
c.removeConn(pconn)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Log a debug message in the context of the client (i.e. not for a
|
|
// particular connection).
|
|
func (c *Client) debug(f string, args ...interface{}) {
|
|
if c.config.Logger == nil {
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(c.config.Logger, "goftp: %.3f %s\n",
|
|
time.Now().Sub(c.t0).Seconds(),
|
|
fmt.Sprintf(f, args...),
|
|
)
|
|
}
|
|
|
|
func (c *Client) numOpenConns() int {
|
|
var numOpen int
|
|
for _, num := range c.numConnsPerHost {
|
|
numOpen += int(num)
|
|
}
|
|
return numOpen
|
|
}
|
|
|
|
// Get an idle connection.
|
|
func (c *Client) getIdleConn() (*persistentConn, error) {
|
|
|
|
// First check for available connections in the channel.
|
|
Loop:
|
|
for {
|
|
select {
|
|
case pconn := <-c.freeConnCh:
|
|
if pconn.broken {
|
|
c.debug("#%d was ready (broken)", pconn.idx)
|
|
c.mu.Lock()
|
|
c.numConnsPerHost[pconn.host]--
|
|
c.mu.Unlock()
|
|
c.removeConn(pconn)
|
|
} else {
|
|
c.debug("#%d was ready", pconn.idx)
|
|
return pconn, nil
|
|
}
|
|
default:
|
|
break Loop
|
|
}
|
|
}
|
|
|
|
// No available connections. Loop until we can open a new one, or
|
|
// one becomes available.
|
|
for {
|
|
c.mu.Lock()
|
|
|
|
// can we open a connection to some host
|
|
if c.numOpenConns() < len(c.hosts)*c.config.ConnectionsPerHost {
|
|
c.connIdx++
|
|
idx := c.connIdx
|
|
|
|
// find the next host with less than ConnectionsPerHost connections
|
|
var host string
|
|
for i := idx; i < idx+len(c.hosts); i++ {
|
|
if c.numConnsPerHost[c.hosts[i%len(c.hosts)]] < c.config.ConnectionsPerHost {
|
|
host = c.hosts[i%len(c.hosts)]
|
|
break
|
|
}
|
|
}
|
|
|
|
if host == "" {
|
|
panic("this shouldn't be possible")
|
|
}
|
|
|
|
c.numConnsPerHost[host]++
|
|
|
|
c.mu.Unlock()
|
|
|
|
pconn, err := c.openConn(idx, host)
|
|
if err != nil {
|
|
c.mu.Lock()
|
|
c.numConnsPerHost[host]--
|
|
c.mu.Unlock()
|
|
c.debug("#%d error connecting: %s", idx, err)
|
|
}
|
|
return pconn, err
|
|
}
|
|
|
|
c.mu.Unlock()
|
|
|
|
// block waiting for a free connection
|
|
pconn := <-c.freeConnCh
|
|
|
|
if pconn.broken {
|
|
c.debug("waited and got #%d (broken)", pconn.idx)
|
|
c.mu.Lock()
|
|
c.numConnsPerHost[pconn.host]--
|
|
c.mu.Unlock()
|
|
c.removeConn(pconn)
|
|
} else {
|
|
c.debug("waited and got #%d", pconn.idx)
|
|
return pconn, nil
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) removeConn(pconn *persistentConn) {
|
|
c.mu.Lock()
|
|
delete(c.allCons, pconn.idx)
|
|
c.mu.Unlock()
|
|
pconn.close()
|
|
}
|
|
|
|
func (c *Client) returnConn(pconn *persistentConn) {
|
|
c.freeConnCh <- pconn
|
|
}
|
|
|
|
// OpenRawConn opens a "raw" connection to the server which allows you to run any control
|
|
// or data command you want. See the RawConn interface for more details. The RawConn will
|
|
// not participate in the Client's pool (i.e. does not count against ConnectionsPerHost).
|
|
func (c *Client) OpenRawConn() (RawConn, error) {
|
|
c.mu.Lock()
|
|
idx := c.rawConnIdx
|
|
host := c.hosts[idx%len(c.hosts)]
|
|
c.rawConnIdx++
|
|
c.mu.Unlock()
|
|
return c.openConn(-(idx + 1), host)
|
|
}
|
|
|
|
// Open and set up a control connection.
|
|
func (c *Client) openConn(idx int, host string) (pconn *persistentConn, err error) {
|
|
pconn = &persistentConn{
|
|
idx: idx,
|
|
features: make(map[string]string),
|
|
config: c.config,
|
|
t0: c.t0,
|
|
currentType: "A",
|
|
host: host,
|
|
epsvNotSupported: c.config.DisableEPSV,
|
|
}
|
|
|
|
var conn net.Conn
|
|
|
|
if c.config.TLSConfig != nil && c.config.TLSMode == TLSImplicit {
|
|
pconn.debug("opening TLS control connection to %s", host)
|
|
dialer := &net.Dialer{
|
|
Timeout: c.config.Timeout,
|
|
}
|
|
conn, err = tls.DialWithDialer(dialer, "tcp", host, pconn.config.TLSConfig)
|
|
} else {
|
|
pconn.debug("opening control connection to %s", host)
|
|
conn, err = net.DialTimeout("tcp", host, c.config.Timeout)
|
|
}
|
|
|
|
var (
|
|
code int
|
|
msg string
|
|
)
|
|
|
|
if err != nil {
|
|
var isTemporary bool
|
|
if ne, ok := err.(net.Error); ok {
|
|
isTemporary = ne.Temporary()
|
|
}
|
|
err = ftpError{
|
|
err: err,
|
|
temporary: isTemporary,
|
|
}
|
|
goto Error
|
|
}
|
|
|
|
pconn.setControlConn(conn)
|
|
|
|
code, msg, err = pconn.readResponse()
|
|
if err != nil {
|
|
goto Error
|
|
}
|
|
|
|
if code != replyServiceReady {
|
|
err = ftpError{code: code, msg: msg}
|
|
goto Error
|
|
}
|
|
|
|
if c.config.TLSConfig != nil && c.config.TLSMode == TLSExplicit {
|
|
err = pconn.logInTLS()
|
|
} else {
|
|
err = pconn.logIn()
|
|
}
|
|
|
|
if err != nil {
|
|
goto Error
|
|
}
|
|
|
|
if err = pconn.fetchFeatures(); err != nil {
|
|
goto Error
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed {
|
|
err = ftpError{err: errors.New("client closed")}
|
|
goto Error
|
|
}
|
|
|
|
if idx >= 0 {
|
|
c.allCons[idx] = pconn
|
|
}
|
|
return pconn, nil
|
|
|
|
Error:
|
|
pconn.close()
|
|
return nil, err
|
|
}
|