2926 lines
96 KiB
Python
2926 lines
96 KiB
Python
import asynchat # LOCAL
|
|
import contextlib
|
|
import errno
|
|
import glob
|
|
import logging
|
|
import os
|
|
import random
|
|
import socket
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from datetime import datetime
|
|
|
|
|
|
try:
|
|
import grp
|
|
import pwd
|
|
except ImportError:
|
|
pwd = grp = None
|
|
|
|
try:
|
|
from OpenSSL import SSL
|
|
except ImportError:
|
|
SSL = None
|
|
|
|
from . import __ver__
|
|
from .authorizers import AuthenticationFailed
|
|
from .authorizers import AuthorizerError
|
|
from .authorizers import DummyAuthorizer
|
|
from .filesystems import AbstractedFS
|
|
from .filesystems import FilesystemError
|
|
from .ioloop import _ERRNOS_DISCONNECTED
|
|
from .ioloop import _ERRNOS_RETRY
|
|
from .ioloop import Acceptor
|
|
from .ioloop import AsyncChat
|
|
from .ioloop import Connector
|
|
from .ioloop import RetryError
|
|
from .ioloop import timer
|
|
from .log import debug
|
|
from .log import logger
|
|
|
|
|
|
CR_BYTE = ord('\r')
|
|
|
|
|
|
proto_cmds = {
|
|
'ABOR': dict(
|
|
perm=None, auth=True, arg=False, help='Syntax: ABOR (abort transfer).'
|
|
),
|
|
'ALLO': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: ALLO <SP> bytes (noop; allocate storage).',
|
|
),
|
|
'APPE': dict(
|
|
perm='a',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: APPE <SP> file-name (append data to file).',
|
|
),
|
|
'CDUP': dict(
|
|
perm='e',
|
|
auth=True,
|
|
arg=False,
|
|
help='Syntax: CDUP (go to parent directory).',
|
|
),
|
|
'CWD': dict(
|
|
perm='e',
|
|
auth=True,
|
|
arg=None,
|
|
help='Syntax: CWD [<SP> dir-name] (change working directory).',
|
|
),
|
|
'DELE': dict(
|
|
perm='d',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: DELE <SP> file-name (delete file).',
|
|
),
|
|
'EPRT': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: EPRT <SP> |proto|ip|port| (extended active mode).',
|
|
),
|
|
'EPSV': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=None,
|
|
help='Syntax: EPSV [<SP> proto/"ALL"] (extended passive mode).',
|
|
),
|
|
'FEAT': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=False,
|
|
help='Syntax: FEAT (list all new features supported).',
|
|
),
|
|
'HELP': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=None,
|
|
help='Syntax: HELP [<SP> cmd] (show help).',
|
|
),
|
|
'LIST': dict(
|
|
perm='l',
|
|
auth=True,
|
|
arg=None,
|
|
help='Syntax: LIST [<SP> path] (list files).',
|
|
),
|
|
'MDTM': dict(
|
|
perm='l',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: MDTM [<SP> path] (file last modification time).',
|
|
),
|
|
'MFMT': dict(
|
|
perm='T',
|
|
auth=True,
|
|
arg=True,
|
|
help=(
|
|
'Syntax: MFMT <SP> timeval <SP> path (file update last '
|
|
'modification time).'
|
|
),
|
|
),
|
|
'MLSD': dict(
|
|
perm='l',
|
|
auth=True,
|
|
arg=None,
|
|
help='Syntax: MLSD [<SP> path] (list directory).',
|
|
),
|
|
'MLST': dict(
|
|
perm='l',
|
|
auth=True,
|
|
arg=None,
|
|
help='Syntax: MLST [<SP> path] (show information about path).',
|
|
),
|
|
'MODE': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: MODE <SP> mode (noop; set data transfer mode).',
|
|
),
|
|
'MKD': dict(
|
|
perm='m',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: MKD <SP> path (create directory).',
|
|
),
|
|
'NLST': dict(
|
|
perm='l',
|
|
auth=True,
|
|
arg=None,
|
|
help='Syntax: NLST [<SP> path] (list path in a compact form).',
|
|
),
|
|
'NOOP': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=False,
|
|
help='Syntax: NOOP (just do nothing).',
|
|
),
|
|
'OPTS': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: OPTS <SP> cmd [<SP> option] (set option for command).',
|
|
),
|
|
'PASS': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=None,
|
|
help='Syntax: PASS [<SP> password] (set user password).',
|
|
),
|
|
'PASV': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=False,
|
|
help='Syntax: PASV (open passive data connection).',
|
|
),
|
|
'PORT': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: PORT <sp> h,h,h,h,p,p (open active data connection).',
|
|
),
|
|
'PWD': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=False,
|
|
help='Syntax: PWD (get current working directory).',
|
|
),
|
|
'QUIT': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=False,
|
|
help='Syntax: QUIT (quit current session).',
|
|
),
|
|
'REIN': dict(
|
|
perm=None, auth=True, arg=False, help='Syntax: REIN (flush account).'
|
|
),
|
|
'REST': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: REST <SP> offset (set file offset).',
|
|
),
|
|
'RETR': dict(
|
|
perm='r',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: RETR <SP> file-name (retrieve a file).',
|
|
),
|
|
'RMD': dict(
|
|
perm='d',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: RMD <SP> dir-name (remove directory).',
|
|
),
|
|
'RNFR': dict(
|
|
perm='f',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: RNFR <SP> file-name (rename (source name)).',
|
|
),
|
|
'RNTO': dict(
|
|
perm='f',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: RNTO <SP> file-name (rename (destination name)).',
|
|
),
|
|
'SITE': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=True,
|
|
help='Syntax: SITE <SP> site-command (execute SITE command).',
|
|
),
|
|
'SITE HELP': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=None,
|
|
help='Syntax: SITE HELP [<SP> cmd] (show SITE command help).',
|
|
),
|
|
'SITE CHMOD': dict(
|
|
perm='M',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: SITE CHMOD <SP> mode path (change file mode).',
|
|
),
|
|
'SIZE': dict(
|
|
perm='l',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: SIZE <SP> file-name (get file size).',
|
|
),
|
|
'STAT': dict(
|
|
perm='l',
|
|
auth=False,
|
|
arg=None,
|
|
help='Syntax: STAT [<SP> path name] (server stats [list files]).',
|
|
),
|
|
'STOR': dict(
|
|
perm='w',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: STOR <SP> file-name (store a file).',
|
|
),
|
|
'STOU': dict(
|
|
perm='w',
|
|
auth=True,
|
|
arg=None,
|
|
help='Syntax: STOU [<SP> name] (store a file with a unique name).',
|
|
),
|
|
'STRU': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: STRU <SP> type (noop; set file structure).',
|
|
),
|
|
'SYST': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=False,
|
|
help='Syntax: SYST (get operating system type).',
|
|
),
|
|
'TYPE': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: TYPE <SP> [A | I] (set transfer type).',
|
|
),
|
|
'USER': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=True,
|
|
help='Syntax: USER <SP> user-name (set username).',
|
|
),
|
|
'XCUP': dict(
|
|
perm='e',
|
|
auth=True,
|
|
arg=False,
|
|
help='Syntax: XCUP (obsolete; go to parent directory).',
|
|
),
|
|
'XCWD': dict(
|
|
perm='e',
|
|
auth=True,
|
|
arg=None,
|
|
help='Syntax: XCWD [<SP> dir-name] (obsolete; change directory).',
|
|
),
|
|
'XMKD': dict(
|
|
perm='m',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: XMKD <SP> dir-name (obsolete; create directory).',
|
|
),
|
|
'XPWD': dict(
|
|
perm=None,
|
|
auth=True,
|
|
arg=False,
|
|
help='Syntax: XPWD (obsolete; get current dir).',
|
|
),
|
|
'XRMD': dict(
|
|
perm='d',
|
|
auth=True,
|
|
arg=True,
|
|
help='Syntax: XRMD <SP> dir-name (obsolete; remove directory).',
|
|
),
|
|
}
|
|
|
|
if not hasattr(os, 'chmod'):
|
|
del proto_cmds['SITE CHMOD']
|
|
|
|
|
|
def _strerror(err):
|
|
if isinstance(err, EnvironmentError):
|
|
try:
|
|
return os.strerror(err.errno)
|
|
except AttributeError:
|
|
# not available on PythonCE
|
|
if not hasattr(os, 'strerror'):
|
|
return err.strerror
|
|
raise
|
|
else:
|
|
return str(err)
|
|
|
|
|
|
def _is_ssl_sock(sock):
|
|
return SSL is not None and isinstance(sock, SSL.Connection)
|
|
|
|
|
|
def _support_hybrid_ipv6():
|
|
try:
|
|
if not socket.has_ipv6:
|
|
return False
|
|
with contextlib.closing(socket.socket(socket.AF_INET6)) as sock:
|
|
return not sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY)
|
|
except (OSError, AttributeError):
|
|
return False
|
|
|
|
|
|
SUPPORTS_HYBRID_IPV6 = _support_hybrid_ipv6()
|
|
|
|
|
|
class _FileReadWriteError(OSError):
|
|
pass
|
|
|
|
|
|
class _GiveUpOnSendfile(Exception):
|
|
pass
|
|
|
|
class PassiveDTP(Acceptor):
|
|
|
|
timeout = 30
|
|
backlog = None
|
|
|
|
def __init__(self, cmd_channel, extmode=False):
|
|
|
|
self.cmd_channel = cmd_channel
|
|
self.log = cmd_channel.log
|
|
self.log_exception = cmd_channel.log_exception
|
|
Acceptor.__init__(self, ioloop=cmd_channel.ioloop)
|
|
|
|
local_ip = self.cmd_channel.socket.getsockname()[0]
|
|
if local_ip in self.cmd_channel.masquerade_address_map:
|
|
masqueraded_ip = self.cmd_channel.masquerade_address_map[local_ip]
|
|
elif self.cmd_channel.masquerade_address:
|
|
masqueraded_ip = self.cmd_channel.masquerade_address
|
|
else:
|
|
masqueraded_ip = None
|
|
|
|
if self.cmd_channel.server.socket.family != socket.AF_INET:
|
|
af = self.bind_af_unspecified((local_ip, 0))
|
|
self.socket.close()
|
|
self.del_channel()
|
|
else:
|
|
af = self.cmd_channel.socket.family
|
|
|
|
self.create_socket(af, socket.SOCK_STREAM)
|
|
|
|
if self.cmd_channel.passive_ports is None:
|
|
self.bind((local_ip, 0))
|
|
else:
|
|
ports = list(self.cmd_channel.passive_ports)
|
|
while ports:
|
|
port = ports.pop(random.randint(0, len(ports) - 1))
|
|
self.set_reuse_addr()
|
|
try:
|
|
self.bind((local_ip, port))
|
|
except PermissionError:
|
|
self.cmd_channel.log(
|
|
f"ignoring EPERM when bind()ing port {port}",
|
|
logfun=logger.debug,
|
|
)
|
|
except OSError as err:
|
|
if err.errno == errno.EADDRINUSE:
|
|
if ports:
|
|
continue
|
|
else:
|
|
self.bind((local_ip, 0))
|
|
self.cmd_channel.log(
|
|
"Can't find a valid passive port in the "
|
|
"configured range. A random kernel-assigned "
|
|
"port will be used.",
|
|
logfun=logger.warning,
|
|
)
|
|
else:
|
|
raise
|
|
else:
|
|
break
|
|
self.listen(self.backlog or self.cmd_channel.server.backlog)
|
|
|
|
port = self.socket.getsockname()[1]
|
|
if not extmode:
|
|
ip = masqueraded_ip or local_ip
|
|
if ip.startswith('::ffff:'):
|
|
ip = ip[7:]
|
|
resp = '227 Entering passive mode (%s,%d,%d).' % (
|
|
ip.replace('.', ','),
|
|
port // 256,
|
|
port % 256,
|
|
)
|
|
self.cmd_channel.respond(resp)
|
|
else:
|
|
self.cmd_channel.respond(
|
|
f'229 Entering extended passive mode (|||{int(port)}|).'
|
|
)
|
|
if self.timeout:
|
|
self.call_later(self.timeout, self.handle_timeout)
|
|
|
|
def handle_accepted(self, sock, addr):
|
|
if not self.cmd_channel.connected:
|
|
return self.close()
|
|
|
|
if self.cmd_channel.remote_ip != addr[0]:
|
|
if not self.cmd_channel.permit_foreign_addresses:
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
msg = (
|
|
'425 Rejected data connection from foreign address '
|
|
f'{addr[0]}:{addr[1]}.'
|
|
)
|
|
self.cmd_channel.respond_w_warning(msg)
|
|
return
|
|
else:
|
|
msg = (
|
|
'Established data connection with foreign address '
|
|
f'{addr[0]}:{addr[1]}.'
|
|
)
|
|
self.cmd_channel.log(msg, logfun=logger.warning)
|
|
self.close()
|
|
if self.cmd_channel.connected:
|
|
handler = self.cmd_channel.dtp_handler(sock, self.cmd_channel)
|
|
if handler.connected:
|
|
self.cmd_channel.data_channel = handler
|
|
self.cmd_channel._on_dtp_connection()
|
|
|
|
def handle_timeout(self):
|
|
if self.cmd_channel.connected:
|
|
self.cmd_channel.respond(
|
|
"421 Passive data channel timed out.", logfun=logger.info
|
|
)
|
|
self.close()
|
|
|
|
def handle_error(self):
|
|
try:
|
|
raise # noqa: PLE0704
|
|
except Exception:
|
|
logger.error(traceback.format_exc())
|
|
try:
|
|
self.close()
|
|
except Exception:
|
|
logger.critical(traceback.format_exc())
|
|
|
|
def close(self):
|
|
debug("call: close()", inst=self)
|
|
Acceptor.close(self)
|
|
|
|
|
|
class ActiveDTP(Connector):
|
|
|
|
timeout = 30
|
|
|
|
def __init__(self, ip, port, cmd_channel):
|
|
|
|
Connector.__init__(self, ioloop=cmd_channel.ioloop)
|
|
self.cmd_channel = cmd_channel
|
|
self.log = cmd_channel.log
|
|
self.log_exception = cmd_channel.log_exception
|
|
self._idler = None
|
|
if self.timeout:
|
|
self._idler = self.ioloop.call_later(
|
|
self.timeout, self.handle_timeout, _errback=self.handle_error
|
|
)
|
|
|
|
if ip.count('.') == 3:
|
|
self._cmd = "PORT"
|
|
self._normalized_addr = f"{ip}:{port}"
|
|
else:
|
|
self._cmd = "EPRT"
|
|
self._normalized_addr = f"[{ip}]:{port}"
|
|
|
|
source_ip = self.cmd_channel.socket.getsockname()[0]
|
|
try:
|
|
self.connect_af_unspecified((ip, port), (source_ip, 0))
|
|
except (socket.gaierror, OSError):
|
|
self.handle_close()
|
|
|
|
def readable(self):
|
|
return False
|
|
|
|
def handle_connect(self):
|
|
self.del_channel()
|
|
if self._idler is not None and not self._idler.cancelled:
|
|
self._idler.cancel()
|
|
if not self.cmd_channel.connected:
|
|
return self.close()
|
|
err = self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
|
if err != 0:
|
|
raise OSError(err)
|
|
msg = 'Active data connection established.'
|
|
self.cmd_channel.respond('200 ' + msg)
|
|
self.cmd_channel.log_cmd(self._cmd, self._normalized_addr, 200, msg)
|
|
if not self.cmd_channel.connected:
|
|
return self.close()
|
|
handler = self.cmd_channel.dtp_handler(self.socket, self.cmd_channel)
|
|
self.cmd_channel.data_channel = handler
|
|
self.cmd_channel._on_dtp_connection()
|
|
|
|
def handle_timeout(self):
|
|
if self.cmd_channel.connected:
|
|
msg = "Active data channel timed out."
|
|
self.cmd_channel.respond("421 " + msg, logfun=logger.info)
|
|
self.cmd_channel.log_cmd(
|
|
self._cmd, self._normalized_addr, 421, msg
|
|
)
|
|
self.close()
|
|
|
|
def handle_close(self):
|
|
if not self._closed:
|
|
self.close()
|
|
if self.cmd_channel.connected:
|
|
msg = "Can't connect to specified address."
|
|
self.cmd_channel.respond("425 " + msg)
|
|
self.cmd_channel.log_cmd(
|
|
self._cmd, self._normalized_addr, 425, msg
|
|
)
|
|
|
|
def handle_error(self):
|
|
try:
|
|
raise # noqa: PLE0704
|
|
except (socket.gaierror, OSError):
|
|
pass
|
|
except Exception:
|
|
self.log_exception(self)
|
|
try:
|
|
self.handle_close()
|
|
except Exception:
|
|
logger.critical(traceback.format_exc())
|
|
|
|
def close(self):
|
|
debug("call: close()", inst=self)
|
|
if not self._closed:
|
|
Connector.close(self)
|
|
if self._idler is not None and not self._idler.cancelled:
|
|
self._idler.cancel()
|
|
|
|
|
|
class DTPHandler(AsyncChat):
|
|
|
|
timeout = 300
|
|
ac_in_buffer_size = 65536
|
|
ac_out_buffer_size = 65536
|
|
|
|
def __init__(self, sock, cmd_channel):
|
|
|
|
self.cmd_channel = cmd_channel
|
|
self.file_obj = None
|
|
self.receive = False
|
|
self.transfer_finished = False
|
|
self.tot_bytes_sent = 0
|
|
self.tot_bytes_received = 0
|
|
self.cmd = None
|
|
self.log = cmd_channel.log
|
|
self.log_exception = cmd_channel.log_exception
|
|
self._data_wrapper = None
|
|
self._lastdata = 0
|
|
self._had_cr = False
|
|
self._start_time = timer()
|
|
self._resp = ()
|
|
self._offset = None
|
|
self._filefd = None
|
|
self._idler = None
|
|
self._initialized = False
|
|
try:
|
|
AsyncChat.__init__(self, sock, ioloop=cmd_channel.ioloop)
|
|
except OSError as err:
|
|
AsyncChat.__init__(
|
|
self, socket.socket(), ioloop=cmd_channel.ioloop
|
|
)
|
|
self.close()
|
|
if err.errno == errno.EINVAL:
|
|
return
|
|
self.handle_error()
|
|
return
|
|
|
|
if not self.connected:
|
|
self.close()
|
|
return
|
|
if self.timeout:
|
|
self._idler = self.ioloop.call_every(
|
|
self.timeout, self.handle_timeout, _errback=self.handle_error
|
|
)
|
|
|
|
def __repr__(self):
|
|
return '<%s(%s)>' % (
|
|
self.__class__.__name__,
|
|
self.cmd_channel.get_repr_info(as_str=True),
|
|
)
|
|
|
|
__str__ = __repr__
|
|
|
|
def use_sendfile(self):
|
|
if not self.cmd_channel.use_sendfile:
|
|
return False
|
|
if self.file_obj is None or not hasattr(self.file_obj, "fileno"):
|
|
return False
|
|
try:
|
|
self.file_obj.fileno()
|
|
except (OSError, ValueError):
|
|
return False
|
|
if self.cmd_channel._current_type != 'i':
|
|
return False
|
|
return True
|
|
|
|
def push(self, data):
|
|
self._initialized = True
|
|
self.modify_ioloop_events(self.ioloop.WRITE)
|
|
self._wanted_io_events = self.ioloop.WRITE
|
|
AsyncChat.push(self, data)
|
|
|
|
def push_with_producer(self, producer):
|
|
self._initialized = True
|
|
self.modify_ioloop_events(self.ioloop.WRITE)
|
|
self._wanted_io_events = self.ioloop.WRITE
|
|
if self.use_sendfile():
|
|
self._offset = producer.file.tell()
|
|
self._filefd = self.file_obj.fileno()
|
|
try:
|
|
self.initiate_sendfile()
|
|
except _GiveUpOnSendfile:
|
|
pass
|
|
else:
|
|
self.initiate_send = self.initiate_sendfile
|
|
return
|
|
debug("starting transfer using send()", self)
|
|
AsyncChat.push_with_producer(self, producer)
|
|
|
|
def close_when_done(self):
|
|
asynchat.async_chat.close_when_done(self)
|
|
|
|
def initiate_send(self):
|
|
asynchat.async_chat.initiate_send(self)
|
|
|
|
def initiate_sendfile(self):
|
|
try:
|
|
sent = os.sendfile(
|
|
self._fileno,
|
|
self._filefd,
|
|
self._offset,
|
|
self.ac_out_buffer_size,
|
|
)
|
|
except OSError as err:
|
|
if err.errno in _ERRNOS_RETRY or err.errno == errno.EBUSY:
|
|
return
|
|
elif err.errno in _ERRNOS_DISCONNECTED:
|
|
self.handle_close()
|
|
elif self.tot_bytes_sent == 0:
|
|
logger.warning(
|
|
"sendfile() failed; falling back on using plain send"
|
|
)
|
|
raise _GiveUpOnSendfile
|
|
else:
|
|
raise
|
|
else:
|
|
if sent == 0:
|
|
# this signals the channel that the transfer is completed
|
|
self.discard_buffers()
|
|
self.handle_close()
|
|
else:
|
|
self._offset += sent
|
|
self.tot_bytes_sent += sent
|
|
|
|
# --- utility methods
|
|
|
|
def _posix_ascii_data_wrapper(self, chunk):
|
|
if self._had_cr:
|
|
chunk = b'\r' + chunk
|
|
|
|
if chunk.endswith(b'\r'):
|
|
self._had_cr = True
|
|
chunk = chunk[:-1]
|
|
else:
|
|
self._had_cr = False
|
|
|
|
return chunk.replace(b'\r\n', bytes(os.linesep, "ascii"))
|
|
|
|
def enable_receiving(self, type, cmd):
|
|
self._initialized = True
|
|
self.modify_ioloop_events(self.ioloop.READ)
|
|
self._wanted_io_events = self.ioloop.READ
|
|
self.cmd = cmd
|
|
if type == 'a':
|
|
if os.linesep == '\r\n':
|
|
self._data_wrapper = None
|
|
else:
|
|
self._data_wrapper = self._posix_ascii_data_wrapper
|
|
elif type == 'i':
|
|
self._data_wrapper = None
|
|
else:
|
|
raise TypeError("unsupported type")
|
|
self.receive = True
|
|
|
|
def get_transmitted_bytes(self):
|
|
return self.tot_bytes_sent + self.tot_bytes_received
|
|
|
|
def get_elapsed_time(self):
|
|
return timer() - self._start_time
|
|
|
|
def transfer_in_progress(self):
|
|
return self.get_transmitted_bytes() != 0
|
|
|
|
# --- connection
|
|
|
|
def send(self, data):
|
|
result = AsyncChat.send(self, data)
|
|
self.tot_bytes_sent += result
|
|
return result
|
|
|
|
def refill_buffer(self):
|
|
while True:
|
|
if len(self.producer_fifo):
|
|
p = self.producer_fifo.first()
|
|
if p is None:
|
|
if not self.ac_out_buffer:
|
|
self.producer_fifo.pop()
|
|
self.handle_close()
|
|
return
|
|
elif isinstance(p, str):
|
|
self.producer_fifo.pop()
|
|
self.ac_out_buffer += p
|
|
return
|
|
data = p.more()
|
|
if data:
|
|
self.ac_out_buffer += data
|
|
return
|
|
else:
|
|
self.producer_fifo.pop()
|
|
else:
|
|
return
|
|
|
|
def handle_read(self):
|
|
try:
|
|
chunk = self.recv(self.ac_in_buffer_size)
|
|
except RetryError:
|
|
pass
|
|
except OSError:
|
|
self.handle_error()
|
|
else:
|
|
self.tot_bytes_received += len(chunk)
|
|
if not chunk:
|
|
self.transfer_finished = True
|
|
return
|
|
if self._data_wrapper is not None:
|
|
chunk = self._data_wrapper(chunk)
|
|
try:
|
|
self.file_obj.write(chunk)
|
|
except OSError as err:
|
|
raise _FileReadWriteError(err)
|
|
|
|
handle_read_event = handle_read
|
|
|
|
def readable(self):
|
|
if not self.receive and not self._initialized:
|
|
return self.close()
|
|
return self.receive
|
|
|
|
def writable(self):
|
|
return not self.receive and asynchat.async_chat.writable(self)
|
|
|
|
def handle_timeout(self):
|
|
if self.get_transmitted_bytes() > self._lastdata:
|
|
self._lastdata = self.get_transmitted_bytes()
|
|
else:
|
|
msg = "Data connection timed out."
|
|
self._resp = ("421 " + msg, logger.info)
|
|
self.close()
|
|
self.cmd_channel.close_when_done()
|
|
|
|
def handle_error(self):
|
|
try:
|
|
raise
|
|
except _FileReadWriteError as err:
|
|
error = _strerror(err.errno)
|
|
except Exception:
|
|
self.log_exception(self)
|
|
error = "Internal error"
|
|
try:
|
|
self._resp = (f"426 {error}; transfer aborted.", logger.warning)
|
|
self.close()
|
|
except Exception:
|
|
logger.critical(traceback.format_exc())
|
|
|
|
def handle_close(self):
|
|
if not self._closed:
|
|
if self.receive:
|
|
self.transfer_finished = True
|
|
else:
|
|
self.transfer_finished = len(self.producer_fifo) == 0
|
|
try:
|
|
if self.transfer_finished:
|
|
self._resp = ("226 Transfer complete.", logger.debug)
|
|
else:
|
|
tot_bytes = self.get_transmitted_bytes()
|
|
self._resp = (
|
|
(
|
|
f"426 Transfer aborted; {int(tot_bytes)} bytes"
|
|
" transmitted."
|
|
),
|
|
logger.debug,
|
|
)
|
|
finally:
|
|
self.close()
|
|
|
|
def close(self):
|
|
debug("call: close()", inst=self)
|
|
if not self._closed:
|
|
AsyncChat.close(self)
|
|
|
|
if self.file_obj is not None and not self.file_obj.closed:
|
|
self.file_obj.close()
|
|
|
|
if self._resp:
|
|
self.cmd_channel.respond(self._resp[0], logfun=self._resp[1])
|
|
|
|
if self._idler is not None and not self._idler.cancelled:
|
|
self._idler.cancel()
|
|
if self.file_obj is not None:
|
|
filename = self.file_obj.name
|
|
elapsed_time = round(self.get_elapsed_time(), 3)
|
|
self.cmd_channel.log_transfer(
|
|
cmd=self.cmd,
|
|
filename=self.file_obj.name,
|
|
receive=self.receive,
|
|
completed=self.transfer_finished,
|
|
elapsed=elapsed_time,
|
|
bytes=self.get_transmitted_bytes(),
|
|
)
|
|
if self.transfer_finished:
|
|
if self.receive:
|
|
self.cmd_channel.on_file_received(filename)
|
|
else:
|
|
self.cmd_channel.on_file_sent(filename)
|
|
elif self.receive:
|
|
self.cmd_channel.on_incomplete_file_received(filename)
|
|
else:
|
|
self.cmd_channel.on_incomplete_file_sent(filename)
|
|
self.cmd_channel._on_dtp_close()
|
|
|
|
|
|
class ThrottledDTPHandler(DTPHandler):
|
|
|
|
read_limit = 0
|
|
write_limit = 0
|
|
auto_sized_buffers = True
|
|
|
|
def __init__(self, sock, cmd_channel):
|
|
super().__init__(sock, cmd_channel)
|
|
self._timenext = 0
|
|
self._datacount = 0
|
|
self.sleeping = False
|
|
self._throttler = None
|
|
if self.auto_sized_buffers:
|
|
if self.read_limit:
|
|
while self.ac_in_buffer_size > self.read_limit:
|
|
self.ac_in_buffer_size /= 2
|
|
if self.write_limit:
|
|
while self.ac_out_buffer_size > self.write_limit:
|
|
self.ac_out_buffer_size /= 2
|
|
self.ac_in_buffer_size = int(self.ac_in_buffer_size)
|
|
self.ac_out_buffer_size = int(self.ac_out_buffer_size)
|
|
|
|
def __repr__(self):
|
|
return DTPHandler.__repr__(self)
|
|
|
|
def use_sendfile(self):
|
|
return False
|
|
|
|
def recv(self, buffer_size):
|
|
chunk = super().recv(buffer_size)
|
|
if self.read_limit:
|
|
self._throttle_bandwidth(len(chunk), self.read_limit)
|
|
return chunk
|
|
|
|
def send(self, data):
|
|
num_sent = super().send(data)
|
|
if self.write_limit:
|
|
self._throttle_bandwidth(num_sent, self.write_limit)
|
|
return num_sent
|
|
|
|
def _cancel_throttler(self):
|
|
if self._throttler is not None and not self._throttler.cancelled:
|
|
self._throttler.cancel()
|
|
|
|
def _throttle_bandwidth(self, len_chunk, max_speed):
|
|
|
|
self._datacount += len_chunk
|
|
if self._datacount >= max_speed:
|
|
self._datacount = 0
|
|
now = timer()
|
|
sleepfor = (self._timenext - now) * 2
|
|
if sleepfor > 0:
|
|
def unsleep():
|
|
if self.receive:
|
|
event = self.ioloop.READ
|
|
else:
|
|
event = self.ioloop.WRITE
|
|
self.add_channel(events=event)
|
|
|
|
self.del_channel()
|
|
self._cancel_throttler()
|
|
self._throttler = self.ioloop.call_later(
|
|
sleepfor, unsleep, _errback=self.handle_error
|
|
)
|
|
self._timenext = now + 1
|
|
|
|
def close(self):
|
|
self._cancel_throttler()
|
|
super().close()
|
|
|
|
|
|
# --- producers
|
|
|
|
|
|
class FileProducer:
|
|
|
|
buffer_size = 65536
|
|
|
|
def __init__(self, file, type):
|
|
self.file = file
|
|
self.type = type
|
|
self._prev_chunk_endswith_cr = False
|
|
if type == 'a' and os.linesep != '\r\n':
|
|
self._data_wrapper = self._posix_ascii_data_wrapper
|
|
else:
|
|
self._data_wrapper = None
|
|
|
|
def _posix_ascii_data_wrapper(self, chunk):
|
|
|
|
chunk = bytearray(chunk)
|
|
pos = 0
|
|
if self._prev_chunk_endswith_cr and chunk.startswith(b'\n'):
|
|
pos += 1
|
|
while True:
|
|
pos = chunk.find(b'\n', pos)
|
|
if pos == -1:
|
|
break
|
|
if chunk[pos - 1] != CR_BYTE:
|
|
chunk.insert(pos, CR_BYTE)
|
|
pos += 1
|
|
pos += 1
|
|
self._prev_chunk_endswith_cr = chunk.endswith(b'\r')
|
|
return chunk
|
|
|
|
def more(self):
|
|
|
|
try:
|
|
data = self.file.read(self.buffer_size)
|
|
except OSError as err:
|
|
raise _FileReadWriteError(err)
|
|
else:
|
|
if self._data_wrapper is not None:
|
|
data = self._data_wrapper(data)
|
|
return data
|
|
|
|
|
|
class BufferedIteratorProducer:
|
|
|
|
loops = 20
|
|
|
|
def __init__(self, iterator):
|
|
self.iterator = iterator
|
|
|
|
def more(self):
|
|
|
|
buffer = []
|
|
for _ in range(self.loops):
|
|
try:
|
|
buffer.append(next(self.iterator))
|
|
except StopIteration:
|
|
break
|
|
return b''.join(buffer)
|
|
|
|
class FTPHandler(AsyncChat):
|
|
|
|
authorizer = DummyAuthorizer()
|
|
active_dtp = ActiveDTP
|
|
passive_dtp = PassiveDTP
|
|
dtp_handler = DTPHandler
|
|
abstracted_fs = AbstractedFS
|
|
proto_cmds = proto_cmds
|
|
|
|
timeout = 300
|
|
banner = f"pyftpdlib {__ver__} ready."
|
|
max_login_attempts = 3
|
|
permit_foreign_addresses = False
|
|
permit_privileged_ports = False
|
|
masquerade_address = None
|
|
masquerade_address_map = {}
|
|
passive_ports = None
|
|
use_gmt_times = True
|
|
use_sendfile = hasattr(os, "sendfile") # added in python 3.3
|
|
tcp_no_delay = hasattr(socket, "TCP_NODELAY")
|
|
unicode_errors = 'replace'
|
|
log_prefix = '%(remote_ip)s:%(remote_port)s-[%(username)s]'
|
|
auth_failed_timeout = 3
|
|
|
|
@property
|
|
def encoding(self):
|
|
return self.__encoding
|
|
@encoding.setter
|
|
def encoding(self,value):
|
|
self.__encoding=value
|
|
self.fs.encoding=value
|
|
|
|
def __init__(self, conn, server, ioloop=None):
|
|
|
|
# public session attributes
|
|
self.server = server
|
|
self.fs = None
|
|
self.authenticated = False
|
|
self.username = ""
|
|
self.password = ""
|
|
self.attempted_logins = 0
|
|
self.data_channel = None
|
|
self.remote_ip = ""
|
|
self.remote_port = ""
|
|
self.started = time.time()
|
|
|
|
# private session attributes
|
|
self._last_response = ""
|
|
self._current_type = 'a'
|
|
self._restart_position = 0
|
|
self._quit_pending = False
|
|
self._in_buffer = []
|
|
self._in_buffer_len = 0
|
|
self._epsvall = False
|
|
self._dtp_acceptor = None
|
|
self._dtp_connector = None
|
|
self._in_dtp_queue = None
|
|
self._out_dtp_queue = None
|
|
self._extra_feats = []
|
|
self._current_facts = ['type', 'perm', 'size', 'modify']
|
|
self._rnfr = None
|
|
self._idler = None
|
|
self._log_debug = (
|
|
logging.getLogger('pyftpdlib').getEffectiveLevel() <= logging.DEBUG
|
|
)
|
|
|
|
self.__encoding = "gbk"
|
|
|
|
if os.name == 'posix':
|
|
self._current_facts.append('unique')
|
|
self._available_facts = self._current_facts[:]
|
|
if pwd and grp:
|
|
self._available_facts += ['unix.mode', 'unix.uid', 'unix.gid']
|
|
if os.name == 'nt':
|
|
self._available_facts.append('create')
|
|
|
|
try:
|
|
AsyncChat.__init__(self, conn, ioloop=ioloop)
|
|
except OSError as err:
|
|
AsyncChat.__init__(self, socket.socket(), ioloop=ioloop)
|
|
self.close()
|
|
debug(f"call: FTPHandler.__init__, err {err!r}", self)
|
|
if err.errno == errno.EINVAL:
|
|
return
|
|
self.handle_error()
|
|
return
|
|
self.set_terminator(b"\r\n")
|
|
|
|
try:
|
|
self.remote_ip, self.remote_port = self.socket.getpeername()[:2]
|
|
except OSError as err:
|
|
debug(
|
|
f"call: FTPHandler.__init__, err on getpeername() {err!r}",
|
|
self,
|
|
)
|
|
self.connected = False
|
|
if err.errno in (errno.ENOTCONN, errno.EINVAL):
|
|
self.close()
|
|
else:
|
|
self.handle_error()
|
|
return
|
|
else:
|
|
self.log("FTP session opened (connect)")
|
|
|
|
# try to handle urgent data inline
|
|
try:
|
|
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_OOBINLINE, 1)
|
|
except OSError as err:
|
|
debug(
|
|
f"call: FTPHandler.__init__, err on SO_OOBINLINE {err!r}", self
|
|
)
|
|
|
|
# disable Nagle algorithm for the control socket only, resulting
|
|
# in significantly better performances
|
|
if self.tcp_no_delay:
|
|
try:
|
|
self.socket.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
|
|
except OSError as err:
|
|
debug(
|
|
f"call: FTPHandler.__init__, err on TCP_NODELAY {err!r}",
|
|
self,
|
|
)
|
|
|
|
# remove this instance from IOLoop's socket_map
|
|
if not self.connected:
|
|
self.close()
|
|
return
|
|
|
|
if self.timeout:
|
|
self._idler = self.ioloop.call_later(
|
|
self.timeout, self.handle_timeout, _errback=self.handle_error
|
|
)
|
|
|
|
def get_repr_info(self, as_str=False, extra_info=None):
|
|
if extra_info is None:
|
|
extra_info = {}
|
|
info = {}
|
|
info['id'] = id(self)
|
|
info['addr'] = f"{self.remote_ip}:{self.remote_port}"
|
|
if _is_ssl_sock(self.socket):
|
|
info['ssl'] = True
|
|
if self.username:
|
|
info['user'] = self.username
|
|
# If threads are involved sometimes "self" may be None (?!?).
|
|
dc = getattr(self, 'data_channel', None)
|
|
if dc is not None:
|
|
if _is_ssl_sock(dc.socket):
|
|
info['ssl-data'] = True
|
|
if dc.file_obj:
|
|
if self.data_channel.receive:
|
|
info['sending-file'] = dc.file_obj
|
|
if dc.use_sendfile():
|
|
info['use-sendfile(2)'] = True
|
|
else:
|
|
info['receiving-file'] = dc.file_obj
|
|
info['bytes-trans'] = dc.get_transmitted_bytes()
|
|
info.update(extra_info)
|
|
if as_str:
|
|
return ', '.join([f'{k}={v!r}' for (k, v) in info.items()])
|
|
return info
|
|
|
|
def __repr__(self):
|
|
return f'<{self.__class__.__name__}({self.get_repr_info(True)})>'
|
|
|
|
__str__ = __repr__
|
|
|
|
def handle(self):
|
|
self.on_connect()
|
|
if not self._closed and not self._closing:
|
|
if len(self.banner) <= 75:
|
|
self.respond(f"220 {self.banner!s}")
|
|
else:
|
|
self.push(f'220-{self.banner!s}\r\n')
|
|
self.respond('220 ')
|
|
|
|
def handle_max_cons(self):
|
|
msg = "421 Too many connections. Service temporarily unavailable."
|
|
self.respond_w_warning(msg)
|
|
# If self.push is used, data could not be sent immediately in
|
|
# which case a new "loop" will occur exposing us to the risk of
|
|
# accepting new connections. Since this could cause asyncore to
|
|
# run out of fds in case we're using select() on Windows we
|
|
# immediately close the channel by using close() instead of
|
|
# close_when_done(). If data has not been sent yet client will
|
|
# be silently disconnected.
|
|
self.close()
|
|
|
|
def handle_max_cons_per_ip(self):
|
|
msg = "421 Too many connections from the same IP address."
|
|
self.respond_w_warning(msg)
|
|
self.close_when_done()
|
|
|
|
def handle_timeout(self):
|
|
msg = "Control connection timed out."
|
|
self.respond("421 " + msg, logfun=logger.info)
|
|
self.close_when_done()
|
|
|
|
def readable(self):
|
|
return self.connected and AsyncChat.readable(self)
|
|
|
|
def writable(self):
|
|
return self.connected and AsyncChat.writable(self)
|
|
|
|
def collect_incoming_data(self, data):
|
|
self._in_buffer.append(data)
|
|
self._in_buffer_len += len(data)
|
|
buflimit = 2048
|
|
if self._in_buffer_len > buflimit:
|
|
self.respond_w_warning('500 Command too long.')
|
|
self._in_buffer = []
|
|
self._in_buffer_len = 0
|
|
|
|
def decode(self, bytes):
|
|
return bytes.decode(self.encoding, self.unicode_errors)
|
|
|
|
def found_terminator(self):
|
|
if self._idler is not None and not self._idler.cancelled:
|
|
self._idler.reset()
|
|
|
|
line = b''.join(self._in_buffer)
|
|
try:
|
|
line = self.decode(line)
|
|
except UnicodeDecodeError:
|
|
return self.respond("501 Can't decode command.")
|
|
|
|
self._in_buffer = []
|
|
self._in_buffer_len = 0
|
|
|
|
cmd = line.split(' ')[0].upper()
|
|
arg = line[len(cmd) + 1 :]
|
|
try:
|
|
self.pre_process_command(line, cmd, arg)
|
|
except UnicodeEncodeError:
|
|
self.respond(
|
|
"501 can't decode path (server filesystem encoding is"
|
|
f" {sys.getfilesystemencoding()})"
|
|
)
|
|
|
|
def pre_process_command(self, line, cmd, arg):
|
|
kwargs = {}
|
|
if cmd == "SITE" and arg:
|
|
cmd = f"SITE {arg.split(' ')[0].upper()}"
|
|
arg = line[len(cmd) + 1 :]
|
|
|
|
if cmd != 'PASS':
|
|
self.logline(f"<- {line}")
|
|
else:
|
|
self.logline(f"<- {line.split(' ')[0]} {'*' * 6}")
|
|
|
|
if cmd not in self.proto_cmds:
|
|
if cmd[-4:] in ('ABOR', 'STAT', 'QUIT'):
|
|
cmd = cmd[-4:]
|
|
else:
|
|
msg = f'Command "{cmd}" not understood.'
|
|
self.respond('500 ' + msg)
|
|
if cmd:
|
|
self.log_cmd(cmd, arg, 500, msg)
|
|
return
|
|
|
|
if not arg and self.proto_cmds[cmd]['arg'] is True:
|
|
msg = "Syntax error: command needs an argument."
|
|
self.respond("501 " + msg)
|
|
self.log_cmd(cmd, "", 501, msg)
|
|
return
|
|
if arg and self.proto_cmds[cmd]['arg'] is False:
|
|
msg = "Syntax error: command does not accept arguments."
|
|
self.respond("501 " + msg)
|
|
self.log_cmd(cmd, arg, 501, msg)
|
|
return
|
|
|
|
if not self.authenticated:
|
|
if self.proto_cmds[cmd]['auth'] or (cmd == 'STAT' and arg):
|
|
msg = "Log in with USER and PASS first."
|
|
self.respond("530 " + msg)
|
|
self.log_cmd(cmd, arg, 530, msg)
|
|
else:
|
|
self.process_command(cmd, arg)
|
|
return
|
|
else:
|
|
if (cmd == 'STAT') and not arg:
|
|
self.ftp_STAT('')
|
|
return
|
|
|
|
if self.proto_cmds[cmd]['perm'] and (cmd != 'STOU'):
|
|
if cmd in ('CWD', 'XCWD'):
|
|
arg = self.fs.ftp2fs(arg or '/')
|
|
elif cmd in ('CDUP', 'XCUP'):
|
|
arg = self.fs.ftp2fs('..')
|
|
elif cmd == 'LIST':
|
|
if arg.lower() in ('-a', '-l', '-al', '-la'):
|
|
arg = self.fs.ftp2fs(self.fs.cwd)
|
|
else:
|
|
arg = self.fs.ftp2fs(arg or self.fs.cwd)
|
|
elif cmd == 'STAT':
|
|
if glob.has_magic(arg):
|
|
msg = 'Globbing not supported.'
|
|
self.respond('550 ' + msg)
|
|
self.log_cmd(cmd, arg, 550, msg)
|
|
return
|
|
arg = self.fs.ftp2fs(arg or self.fs.cwd)
|
|
elif cmd == 'SITE CHMOD':
|
|
if ' ' not in arg:
|
|
msg = "Syntax error: command needs two arguments."
|
|
self.respond("501 " + msg)
|
|
self.log_cmd(cmd, "", 501, msg)
|
|
return
|
|
else:
|
|
mode, arg = arg.split(' ', 1)
|
|
arg = self.fs.ftp2fs(arg)
|
|
kwargs = dict(mode=mode)
|
|
elif cmd == 'MFMT':
|
|
if ' ' not in arg:
|
|
msg = "Syntax error: command needs two arguments."
|
|
self.respond("501 " + msg)
|
|
self.log_cmd(cmd, "", 501, msg)
|
|
return
|
|
else:
|
|
timeval, arg = arg.split(' ', 1)
|
|
arg = self.fs.ftp2fs(arg)
|
|
kwargs = dict(timeval=timeval)
|
|
|
|
else: # LIST, NLST, MLSD, MLST
|
|
arg = self.fs.ftp2fs(arg or self.fs.cwd)
|
|
|
|
if not self.fs.validpath(arg):
|
|
line = self.fs.fs2ftp(arg)
|
|
msg = f"{line!r} points to a path which is outside "
|
|
msg += "the user's root directory"
|
|
self.respond(f"550 {msg}.")
|
|
self.log_cmd(cmd, arg, 550, msg)
|
|
return
|
|
|
|
# check permission
|
|
perm = self.proto_cmds[cmd]['perm']
|
|
if perm is not None and cmd != 'STOU':
|
|
if not self.authorizer.has_perm(self.username, perm, arg):
|
|
msg = "Not enough privileges."
|
|
self.respond("550 " + msg)
|
|
self.log_cmd(cmd, arg, 550, msg)
|
|
return
|
|
|
|
# call the proper ftp_* method
|
|
self.process_command(cmd, arg, **kwargs)
|
|
|
|
def process_command(self, cmd, *args, **kwargs):
|
|
if self._closed:
|
|
return
|
|
self._last_response = ""
|
|
method = getattr(self, 'ftp_' + cmd.replace(' ', '_'))
|
|
method(*args, **kwargs)
|
|
if self._last_response:
|
|
code = int(self._last_response[:3])
|
|
resp = self._last_response[4:]
|
|
self.log_cmd(cmd, args[0], code, resp)
|
|
|
|
def handle_error(self):
|
|
try:
|
|
self.log_exception(self)
|
|
self.close()
|
|
except Exception:
|
|
logger.critical(traceback.format_exc())
|
|
|
|
def handle_close(self):
|
|
self.close()
|
|
|
|
def close(self):
|
|
debug("call: close()", inst=self)
|
|
if not self._closed:
|
|
AsyncChat.close(self)
|
|
|
|
self._shutdown_connecting_dtp()
|
|
|
|
if self.data_channel is not None:
|
|
self.data_channel.close()
|
|
del self.data_channel
|
|
|
|
if self._out_dtp_queue is not None:
|
|
file = self._out_dtp_queue[2]
|
|
if file is not None:
|
|
file.close()
|
|
if self._in_dtp_queue is not None:
|
|
file = self._in_dtp_queue[0]
|
|
if file is not None:
|
|
file.close()
|
|
|
|
del self._out_dtp_queue
|
|
del self._in_dtp_queue
|
|
|
|
if self._idler is not None and not self._idler.cancelled:
|
|
self._idler.cancel()
|
|
|
|
if self.remote_ip in self.server.ip_map:
|
|
self.server.ip_map.remove(self.remote_ip)
|
|
|
|
if self.fs is not None:
|
|
self.fs.cmd_channel = None
|
|
self.fs = None
|
|
self.log("FTP session closed (disconnect).")
|
|
if self.remote_ip:
|
|
self.ioloop.call_later(
|
|
0, self.on_disconnect, _errback=self.handle_error
|
|
)
|
|
|
|
def _shutdown_connecting_dtp(self):
|
|
if self._dtp_acceptor is not None:
|
|
self._dtp_acceptor.close()
|
|
self._dtp_acceptor = None
|
|
if self._dtp_connector is not None:
|
|
self._dtp_connector.close()
|
|
self._dtp_connector = None
|
|
|
|
def on_connect(self):
|
|
pass
|
|
|
|
def on_disconnect(self):
|
|
pass
|
|
|
|
def on_login(self, username):
|
|
pass
|
|
|
|
def on_login_failed(self, username, password):
|
|
pass
|
|
|
|
def on_logout(self, username):
|
|
pass
|
|
|
|
def on_file_sent(self, file):
|
|
pass
|
|
|
|
def on_file_received(self, file):
|
|
pass
|
|
|
|
def on_incomplete_file_sent(self, file):
|
|
pass
|
|
|
|
def on_incomplete_file_received(self, file):
|
|
pass
|
|
|
|
def _on_dtp_connection(self):
|
|
pass
|
|
if self._dtp_acceptor is not None:
|
|
self._dtp_acceptor.close()
|
|
self._dtp_acceptor = None
|
|
|
|
if self._idler is not None and not self._idler.cancelled:
|
|
self._idler.cancel()
|
|
|
|
if self._out_dtp_queue is not None:
|
|
data, isproducer, file, cmd = self._out_dtp_queue
|
|
self._out_dtp_queue = None
|
|
self.data_channel.cmd = cmd
|
|
if file:
|
|
self.data_channel.file_obj = file
|
|
try:
|
|
if not isproducer:
|
|
self.data_channel.push(data)
|
|
else:
|
|
self.data_channel.push_with_producer(data)
|
|
if self.data_channel is not None:
|
|
self.data_channel.close_when_done()
|
|
except Exception:
|
|
self.data_channel.handle_error()
|
|
|
|
# check for data to receive
|
|
elif self._in_dtp_queue is not None:
|
|
file, cmd = self._in_dtp_queue
|
|
self.data_channel.file_obj = file
|
|
self._in_dtp_queue = None
|
|
self.data_channel.enable_receiving(self._current_type, cmd)
|
|
|
|
def _on_dtp_close(self):
|
|
self.data_channel = None
|
|
if self._quit_pending:
|
|
self.close()
|
|
elif self.timeout:
|
|
if self._idler is not None and not self._idler.cancelled:
|
|
self._idler.cancel()
|
|
self._idler = self.ioloop.call_later(
|
|
self.timeout, self.handle_timeout, _errback=self.handle_error
|
|
)
|
|
|
|
def push(self, data):
|
|
asynchat.async_chat.push(self, data.encode(self.encoding))
|
|
|
|
def respond(self, resp, logfun=logger.debug):
|
|
self._last_response = resp
|
|
self.push(resp + '\r\n')
|
|
if self._log_debug:
|
|
self.logline(f'-> {resp}', logfun=logfun)
|
|
else:
|
|
self.log(resp[4:], logfun=logfun)
|
|
|
|
def respond_w_warning(self, resp):
|
|
self.respond(resp, logfun=logger.warning)
|
|
|
|
def push_dtp_data(self, data, isproducer=False, file=None, cmd=None):
|
|
if self.data_channel is not None:
|
|
self.respond(
|
|
"125 Data connection already open. Transfer starting."
|
|
)
|
|
if file:
|
|
self.data_channel.file_obj = file
|
|
try:
|
|
if not isproducer:
|
|
self.data_channel.push(data)
|
|
else:
|
|
self.data_channel.push_with_producer(data)
|
|
if self.data_channel is not None:
|
|
self.data_channel.cmd = cmd
|
|
self.data_channel.close_when_done()
|
|
except Exception:
|
|
self.data_channel.handle_error()
|
|
else:
|
|
self.respond(
|
|
"150 File status okay. About to open data connection."
|
|
)
|
|
self._out_dtp_queue = (data, isproducer, file, cmd)
|
|
|
|
def flush_account(self):
|
|
self._shutdown_connecting_dtp()
|
|
# supposed to let it finish
|
|
if self.data_channel is not None:
|
|
if not self.data_channel.transfer_in_progress():
|
|
self.data_channel.close()
|
|
self.data_channel = None
|
|
|
|
username = self.username
|
|
if self.authenticated and username:
|
|
self.on_logout(username)
|
|
self.authenticated = False
|
|
self.username = ""
|
|
self.password = ""
|
|
self.attempted_logins = 0
|
|
self._current_type = 'a'
|
|
self._restart_position = 0
|
|
self._quit_pending = False
|
|
self._in_dtp_queue = None
|
|
self._rnfr = None
|
|
self._out_dtp_queue = None
|
|
|
|
def run_as_current_user(self, function, *args, **kwargs):
|
|
self.authorizer.impersonate_user(self.username, self.password)
|
|
try:
|
|
return function(*args, **kwargs)
|
|
finally:
|
|
self.authorizer.terminate_impersonation(self.username)
|
|
|
|
def log(self, msg, logfun=logger.info):
|
|
prefix = self.log_prefix % self.__dict__
|
|
logfun(f"{prefix} {msg}")
|
|
|
|
def logline(self, msg, logfun=logger.debug):
|
|
if self._log_debug:
|
|
prefix = self.log_prefix % self.__dict__
|
|
logfun(f"{prefix} {msg}")
|
|
|
|
def logerror(self, msg):
|
|
prefix = self.log_prefix % self.__dict__
|
|
logger.error(f"{prefix} {msg}")
|
|
|
|
def log_exception(self, instance):
|
|
logger.exception("unhandled exception in instance %r", instance)
|
|
|
|
log_cmds_list = [
|
|
"DELE",
|
|
"RNFR",
|
|
"RNTO",
|
|
"MKD",
|
|
"RMD",
|
|
"CWD",
|
|
"XMKD",
|
|
"XRMD",
|
|
"XCWD",
|
|
"REIN",
|
|
"SITE CHMOD",
|
|
"MFMT",
|
|
]
|
|
|
|
def log_cmd(self, cmd, arg, respcode, respstr):
|
|
if not self._log_debug and cmd in self.log_cmds_list:
|
|
line = f"{cmd.strip()} {arg.strip()} {respcode}"
|
|
if str(respcode)[0] in ('4', '5'):
|
|
line += f' {respstr!r}'
|
|
self.log(line)
|
|
|
|
def log_transfer(self, cmd, filename, receive, completed, elapsed, bytes):
|
|
line = '%s %s completed=%s bytes=%s seconds=%s' % ( # noqa
|
|
cmd,
|
|
filename,
|
|
(completed and 1) or 0,
|
|
bytes,
|
|
elapsed,
|
|
)
|
|
self.log(line)
|
|
|
|
def _make_eport(self, ip, port):
|
|
remote_ip = self.remote_ip
|
|
if remote_ip.startswith('::ffff:'):
|
|
remote_ip = remote_ip[7:]
|
|
if not self.permit_foreign_addresses and ip != remote_ip:
|
|
msg = (
|
|
f"501 Rejected data connection to foreign address {ip}:{port}."
|
|
)
|
|
self.respond_w_warning(msg)
|
|
return
|
|
|
|
if not self.permit_privileged_ports and port < 1024:
|
|
msg = f'501 PORT against the privileged port "{port}" refused.'
|
|
self.respond_w_warning(msg)
|
|
return
|
|
|
|
self._shutdown_connecting_dtp()
|
|
|
|
if self.data_channel is not None:
|
|
self.data_channel.close()
|
|
self.data_channel = None
|
|
|
|
if not self.server._accept_new_cons():
|
|
msg = "425 Too many connections. Can't open data channel."
|
|
self.respond_w_warning(msg)
|
|
return
|
|
|
|
self._dtp_connector = self.active_dtp(ip, port, self)
|
|
|
|
def _make_epasv(self, extmode=False):
|
|
self._shutdown_connecting_dtp()
|
|
|
|
if self.data_channel is not None:
|
|
self.data_channel.close()
|
|
self.data_channel = None
|
|
|
|
if not self.server._accept_new_cons():
|
|
msg = "425 Too many connections. Can't open data channel."
|
|
self.respond_w_warning(msg)
|
|
return
|
|
|
|
self._dtp_acceptor = self.passive_dtp(self, extmode)
|
|
|
|
def ftp_PORT(self, line):
|
|
if self._epsvall:
|
|
self.respond("501 PORT not allowed after EPSV ALL.")
|
|
return
|
|
try:
|
|
addr = list(map(int, line.split(',')))
|
|
if len(addr) != 6:
|
|
raise ValueError
|
|
for x in addr[:4]:
|
|
if not 0 <= x <= 255:
|
|
raise ValueError
|
|
ip = '%d.%d.%d.%d' % tuple(addr[:4])
|
|
port = (addr[4] * 256) + addr[5]
|
|
if not 0 <= port <= 65535:
|
|
raise ValueError
|
|
except (ValueError, OverflowError):
|
|
self.respond("501 Invalid PORT format.")
|
|
return
|
|
self._make_eport(ip, port)
|
|
|
|
def ftp_EPRT(self, line):
|
|
if self._epsvall:
|
|
self.respond("501 EPRT not allowed after EPSV ALL.")
|
|
return
|
|
try:
|
|
af, ip, port = line.split(line[0])[1:-1]
|
|
port = int(port)
|
|
if not 0 <= port <= 65535:
|
|
raise ValueError
|
|
except (ValueError, IndexError, OverflowError):
|
|
self.respond("501 Invalid EPRT format.")
|
|
return
|
|
|
|
if af == "1":
|
|
if (
|
|
self.socket.family == socket.AF_INET6
|
|
and not SUPPORTS_HYBRID_IPV6
|
|
):
|
|
self.respond('522 Network protocol not supported (use 2).')
|
|
else:
|
|
try:
|
|
octs = list(map(int, ip.split('.')))
|
|
if len(octs) != 4:
|
|
raise ValueError
|
|
for x in octs:
|
|
if not 0 <= x <= 255:
|
|
raise ValueError
|
|
except (ValueError, OverflowError):
|
|
self.respond("501 Invalid EPRT format.")
|
|
else:
|
|
self._make_eport(ip, port)
|
|
elif af == "2":
|
|
if self.socket.family == socket.AF_INET:
|
|
self.respond('522 Network protocol not supported (use 1).')
|
|
else:
|
|
self._make_eport(ip, port)
|
|
elif self.socket.family == socket.AF_INET:
|
|
self.respond('501 Unknown network protocol (use 1).')
|
|
else:
|
|
self.respond('501 Unknown network protocol (use 2).')
|
|
|
|
def ftp_PASV(self, line):
|
|
if self._epsvall:
|
|
self.respond("501 PASV not allowed after EPSV ALL.")
|
|
return
|
|
self._make_epasv(extmode=False)
|
|
|
|
def ftp_EPSV(self, line):
|
|
if not line:
|
|
self._make_epasv(extmode=True)
|
|
# IPv4
|
|
elif line == "1":
|
|
if self.socket.family != socket.AF_INET:
|
|
self.respond('522 Network protocol not supported (use 2).')
|
|
else:
|
|
self._make_epasv(extmode=True)
|
|
# IPv6
|
|
elif line == "2":
|
|
if self.socket.family == socket.AF_INET:
|
|
self.respond('522 Network protocol not supported (use 1).')
|
|
else:
|
|
self._make_epasv(extmode=True)
|
|
elif line.lower() == 'all':
|
|
self._epsvall = True
|
|
self.respond(
|
|
'220 Other commands other than EPSV are now disabled.'
|
|
)
|
|
elif self.socket.family == socket.AF_INET:
|
|
self.respond('501 Unknown network protocol (use 1).')
|
|
else:
|
|
self.respond('501 Unknown network protocol (use 2).')
|
|
|
|
def ftp_QUIT(self, line):
|
|
if self.authenticated:
|
|
msg_quit = self.authorizer.get_msg_quit(self.username)
|
|
else:
|
|
msg_quit = "Goodbye."
|
|
if len(msg_quit) <= 75:
|
|
self.respond(f"221 {msg_quit}")
|
|
else:
|
|
self.push(f"221-{msg_quit}\r\n")
|
|
self.respond("221 ")
|
|
|
|
if self.data_channel:
|
|
self._quit_pending = True
|
|
self.del_channel()
|
|
else:
|
|
self._shutdown_connecting_dtp()
|
|
self.close_when_done()
|
|
if self.authenticated and self.username:
|
|
self.on_logout(self.username)
|
|
|
|
def ftp_LIST(self, path):
|
|
try:
|
|
isdir = self.fs.isdir(path)
|
|
if isdir:
|
|
listing = self.run_as_current_user(self.fs.listdir, path)
|
|
listing.sort()
|
|
iterator = self.fs.format_list(path, listing)
|
|
else:
|
|
basedir, filename = os.path.split(path)
|
|
self.fs.lstat(path)
|
|
iterator = self.fs.format_list(basedir, [filename])
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
producer = BufferedIteratorProducer(iterator)
|
|
self.push_dtp_data(producer, isproducer=True, cmd="LIST")
|
|
return path
|
|
|
|
def ftp_NLST(self, path):
|
|
try:
|
|
if self.fs.isdir(path):
|
|
listing = list(self.run_as_current_user(self.fs.listdir, path))
|
|
else:
|
|
self.fs.lstat(path)
|
|
listing = [os.path.basename(path)]
|
|
except (OSError, FilesystemError) as err:
|
|
self.respond(f'550 {_strerror(err)}.')
|
|
else:
|
|
data = ''
|
|
if listing:
|
|
listing.sort()
|
|
data = '\r\n'.join(listing) + '\r\n'
|
|
data = data.encode(self.encoding, self.unicode_errors)
|
|
self.push_dtp_data(data, cmd="NLST")
|
|
return path
|
|
|
|
def ftp_MLST(self, path):
|
|
line = self.fs.fs2ftp(path)
|
|
basedir, basename = os.path.split(path)
|
|
perms = self.authorizer.get_perms(self.username)
|
|
try:
|
|
iterator = self.run_as_current_user(
|
|
self.fs.format_mlsx,
|
|
basedir,
|
|
[basename],
|
|
perms,
|
|
self._current_facts,
|
|
ignore_err=False,
|
|
)
|
|
data = b''.join(iterator)
|
|
except (OSError, FilesystemError) as err:
|
|
self.respond(f'550 {_strerror(err)}.')
|
|
else:
|
|
data = data.decode(self.encoding, self.unicode_errors)
|
|
data = data.split(' ')[0] + f' {line}\r\n'
|
|
self.push(f'250-Listing "{line}":\r\n')
|
|
self.push(' ' + data)
|
|
self.respond('250 End MLST.')
|
|
return path
|
|
|
|
def ftp_MLSD(self, path):
|
|
if not self.fs.isdir(path):
|
|
self.respond("501 No such directory.")
|
|
return
|
|
try:
|
|
listing = self.run_as_current_user(self.fs.listdir, path)
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
perms = self.authorizer.get_perms(self.username)
|
|
iterator = self.fs.format_mlsx(
|
|
path, listing, perms, self._current_facts
|
|
)
|
|
producer = BufferedIteratorProducer(iterator)
|
|
self.push_dtp_data(producer, isproducer=True, cmd="MLSD")
|
|
return path
|
|
|
|
def ftp_RETR(self, file):
|
|
rest_pos = self._restart_position
|
|
self._restart_position = 0
|
|
try:
|
|
fd = self.run_as_current_user(self.fs.open, file, 'rb')
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
return
|
|
|
|
try:
|
|
if rest_pos:
|
|
ok = 0
|
|
try:
|
|
fsize = self.fs.getsize(file)
|
|
if rest_pos > fsize:
|
|
raise ValueError
|
|
fd.seek(rest_pos)
|
|
ok = 1
|
|
except ValueError:
|
|
why = f"REST position ({rest_pos}) > file size ({fsize})"
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
if not ok:
|
|
fd.close()
|
|
self.respond(f'554 {why}')
|
|
return
|
|
producer = FileProducer(fd, self._current_type)
|
|
self.push_dtp_data(producer, isproducer=True, file=fd, cmd="RETR")
|
|
return file
|
|
except Exception:
|
|
fd.close()
|
|
raise
|
|
|
|
def ftp_STOR(self, file, mode='w'):
|
|
cmd = 'APPE' if 'a' in mode else 'STOR'
|
|
rest_pos = self._restart_position
|
|
self._restart_position = 0
|
|
if rest_pos:
|
|
mode = 'r+'
|
|
try:
|
|
fd = self.run_as_current_user(self.fs.open, file, mode + 'b')
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
return
|
|
|
|
try:
|
|
if rest_pos:
|
|
ok = 0
|
|
try:
|
|
fsize = self.fs.getsize(file)
|
|
if rest_pos > fsize:
|
|
raise ValueError
|
|
fd.seek(rest_pos)
|
|
ok = 1
|
|
except ValueError:
|
|
why = f"REST position ({rest_pos}) > file size ({fsize})"
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
if not ok:
|
|
fd.close()
|
|
self.respond(f'554 {why}')
|
|
return
|
|
|
|
if self.data_channel is not None:
|
|
resp = "Data connection already open. Transfer starting."
|
|
self.respond("125 " + resp)
|
|
self.data_channel.file_obj = fd
|
|
self.data_channel.enable_receiving(self._current_type, cmd)
|
|
else:
|
|
resp = "File status okay. About to open data connection."
|
|
self.respond("150 " + resp)
|
|
self._in_dtp_queue = (fd, cmd)
|
|
return file
|
|
except Exception:
|
|
fd.close()
|
|
raise
|
|
|
|
def ftp_STOU(self, line):
|
|
if self._restart_position:
|
|
self.respond("450 Can't STOU while REST request is pending.")
|
|
return
|
|
|
|
if line:
|
|
basedir, prefix = os.path.split(self.fs.ftp2fs(line))
|
|
prefix += '.'
|
|
else:
|
|
basedir = self.fs.ftp2fs(self.fs.cwd)
|
|
prefix = 'ftpd.'
|
|
try:
|
|
fd = self.run_as_current_user(
|
|
self.fs.mkstemp, prefix=prefix, dir=basedir
|
|
)
|
|
except (OSError, FilesystemError) as err:
|
|
if getattr(err, "errno", -1) == errno.EEXIST:
|
|
why = 'No usable unique file name found'
|
|
else:
|
|
why = _strerror(err)
|
|
self.respond(f"450 {why}.")
|
|
return
|
|
|
|
try:
|
|
if not self.authorizer.has_perm(self.username, 'w', fd.name):
|
|
try:
|
|
fd.close()
|
|
self.run_as_current_user(self.fs.remove, fd.name)
|
|
except (OSError, FilesystemError):
|
|
pass
|
|
self.respond("550 Not enough privileges.")
|
|
return
|
|
|
|
filename = os.path.basename(fd.name)
|
|
if self.data_channel is not None:
|
|
self.respond(f"125 FILE: {filename}")
|
|
self.data_channel.file_obj = fd
|
|
self.data_channel.enable_receiving(self._current_type, "STOU")
|
|
else:
|
|
self.respond(f"150 FILE: {filename}")
|
|
self._in_dtp_queue = (fd, "STOU")
|
|
return filename
|
|
except Exception:
|
|
fd.close()
|
|
raise
|
|
|
|
def ftp_APPE(self, file):
|
|
if self._restart_position:
|
|
self.respond("450 Can't APPE while REST request is pending.")
|
|
else:
|
|
return self.ftp_STOR(file, mode='a')
|
|
|
|
def ftp_REST(self, line):
|
|
if self._current_type == 'a':
|
|
self.respond('501 Resuming transfers not allowed in ASCII mode.')
|
|
return
|
|
try:
|
|
marker = int(line)
|
|
if marker < 0:
|
|
raise ValueError
|
|
except (ValueError, OverflowError):
|
|
self.respond("501 Invalid parameter.")
|
|
else:
|
|
self.respond(f"350 Restarting at position {marker}.")
|
|
self._restart_position = marker
|
|
|
|
def ftp_ABOR(self, line):
|
|
if (
|
|
self._dtp_acceptor is None
|
|
and self._dtp_connector is None
|
|
and self.data_channel is None
|
|
):
|
|
self.respond("225 No transfer to abort.")
|
|
return
|
|
else:
|
|
# a PASV or PORT was received but connection wasn't made yet
|
|
if (
|
|
self._dtp_acceptor is not None
|
|
or self._dtp_connector is not None
|
|
):
|
|
self._shutdown_connecting_dtp()
|
|
resp = "225 ABOR command successful; data channel closed."
|
|
|
|
if self.data_channel is not None:
|
|
if self.data_channel.transfer_in_progress():
|
|
self.data_channel.close()
|
|
self.data_channel = None
|
|
self.respond(
|
|
"426 Transfer aborted via ABOR.", logfun=logger.info
|
|
)
|
|
resp = "226 ABOR command successful."
|
|
else:
|
|
self.data_channel.close()
|
|
self.data_channel = None
|
|
resp = "225 ABOR command successful; data channel closed."
|
|
self.respond(resp)
|
|
|
|
def ftp_USER(self, line):
|
|
if not self.authenticated:
|
|
self.respond('331 Username ok, send password.')
|
|
else:
|
|
self.flush_account()
|
|
msg = 'Previous account information was flushed'
|
|
self.respond(f'331 {msg}, send password.', logfun=logger.info)
|
|
self.username = line
|
|
|
|
def handle_auth_failed(self, msg, password):
|
|
def callback(username, password, msg):
|
|
self.add_channel()
|
|
if hasattr(self, '_closed') and not self._closed:
|
|
self.attempted_logins += 1
|
|
if self.attempted_logins >= self.max_login_attempts:
|
|
msg += " Disconnecting."
|
|
self.respond("530 " + msg)
|
|
self.close_when_done()
|
|
else:
|
|
self.respond("530 " + msg)
|
|
self.log(f"USER '{username}' failed login.")
|
|
self.on_login_failed(username, password)
|
|
|
|
self.del_channel()
|
|
if not msg:
|
|
if self.username == 'anonymous':
|
|
msg = "Anonymous access not allowed."
|
|
else:
|
|
msg = "Authentication failed."
|
|
else:
|
|
msg = msg.capitalize()
|
|
self.ioloop.call_later(
|
|
self.auth_failed_timeout,
|
|
callback,
|
|
self.username,
|
|
password,
|
|
msg,
|
|
_errback=self.handle_error,
|
|
)
|
|
self.username = ""
|
|
|
|
def handle_auth_success(self, home, password, msg_login):
|
|
if len(msg_login) <= 75:
|
|
self.respond(f'230 {msg_login}')
|
|
else:
|
|
self.push(f"230-{msg_login}\r\n")
|
|
self.respond("230 ")
|
|
self.log(f"USER '{self.username}' logged in.")
|
|
self.authenticated = True
|
|
self.password = password
|
|
self.attempted_logins = 0
|
|
|
|
self.fs = self.abstracted_fs(home, self, self.encoding)
|
|
self.on_login(self.username)
|
|
|
|
def ftp_PASS(self, line):
|
|
if self.authenticated:
|
|
self.respond("503 User already authenticated.")
|
|
return
|
|
if not self.username:
|
|
self.respond("503 Login with USER first.")
|
|
return
|
|
|
|
try:
|
|
self.authorizer.validate_authentication(self.username, line, self)
|
|
home = self.authorizer.get_home_dir(self.username)
|
|
msg_login = self.authorizer.get_msg_login(self.username)
|
|
except (AuthenticationFailed, AuthorizerError) as err:
|
|
self.handle_auth_failed(str(err), line)
|
|
else:
|
|
self.handle_auth_success(home, line, msg_login)
|
|
|
|
def ftp_REIN(self, line):
|
|
self.flush_account()
|
|
self.respond("230 Ready for new user.")
|
|
|
|
def ftp_PWD(self, line):
|
|
cwd = self.fs.cwd
|
|
self.respond(
|
|
'257 "%s" is the current directory.'
|
|
% cwd.replace('"', '""')
|
|
)
|
|
|
|
def ftp_CWD(self, path):
|
|
init_cwd = os.getcwd()
|
|
try:
|
|
self.run_as_current_user(self.fs.chdir, path)
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
cwd = self.fs.cwd
|
|
self.respond(f'250 "{cwd}" is the current directory.')
|
|
if os.getcwd() != init_cwd:
|
|
os.chdir(init_cwd)
|
|
return path
|
|
|
|
def ftp_CDUP(self, path):
|
|
return self.ftp_CWD(path)
|
|
|
|
def ftp_SIZE(self, path):
|
|
line = self.fs.fs2ftp(path)
|
|
if self._current_type == 'a':
|
|
why = "SIZE not allowed in ASCII mode"
|
|
self.respond(f"550 {why}.")
|
|
return
|
|
if not self.fs.isfile(self.fs.realpath(path)):
|
|
why = f"{line} is not retrievable"
|
|
self.respond(f"550 {why}.")
|
|
return
|
|
try:
|
|
size = self.run_as_current_user(self.fs.getsize, path)
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.respond(f"213 {size}")
|
|
|
|
def ftp_MDTM(self, path):
|
|
line = self.fs.fs2ftp(path)
|
|
if not self.fs.isfile(self.fs.realpath(path)):
|
|
self.respond(f"550 {line} is not retrievable")
|
|
return
|
|
timefunc = time.gmtime if self.use_gmt_times else time.localtime
|
|
try:
|
|
secs = self.run_as_current_user(self.fs.getmtime, path)
|
|
lmt = time.strftime("%Y%m%d%H%M%S", timefunc(secs))
|
|
except (ValueError, OSError, FilesystemError) as err:
|
|
if isinstance(err, ValueError):
|
|
why = "Can't determine file's last modification time"
|
|
else:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.respond(f"213 {lmt}")
|
|
return path
|
|
|
|
def ftp_MFMT(self, path, timeval):
|
|
line = self.fs.fs2ftp(path)
|
|
|
|
if len(timeval) != len("YYYYMMDDHHMMSS"):
|
|
why = "Invalid time format; expected: YYYYMMDDHHMMSS"
|
|
self.respond(f'550 {why}.')
|
|
return
|
|
if not self.fs.isfile(self.fs.realpath(path)):
|
|
self.respond(f"550 {line} is not retrievable")
|
|
return
|
|
timefunc = time.gmtime if self.use_gmt_times else time.localtime
|
|
try:
|
|
epoch = datetime.utcfromtimestamp(0)
|
|
timeval_datetime_obj = datetime.strptime(timeval, '%Y%m%d%H%M%S')
|
|
timeval_secs = (timeval_datetime_obj - epoch).total_seconds()
|
|
except ValueError:
|
|
why = "Invalid time format; expected: YYYYMMDDHHMMSS"
|
|
self.respond(f'550 {why}.')
|
|
return
|
|
try:
|
|
self.run_as_current_user(self.fs.utime, path, timeval_secs)
|
|
secs = self.run_as_current_user(self.fs.getmtime, path)
|
|
lmt = time.strftime("%Y%m%d%H%M%S", timefunc(secs))
|
|
except (ValueError, OSError, FilesystemError) as err:
|
|
if isinstance(err, ValueError):
|
|
why = "Can't determine file's last modification time"
|
|
else:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.respond(f"213 Modify={lmt}; {line}.")
|
|
return (lmt, path)
|
|
|
|
def ftp_MKD(self, path):
|
|
line = self.fs.fs2ftp(path)
|
|
try:
|
|
self.run_as_current_user(self.fs.mkdir, path)
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.respond(
|
|
'257 "%s" directory created.' % line.replace('"', '""')
|
|
)
|
|
return path
|
|
|
|
def ftp_RMD(self, path):
|
|
if self.fs.realpath(path) == self.fs.realpath(self.fs.root):
|
|
msg = "Can't remove root directory."
|
|
self.respond(f"550 {msg}")
|
|
return
|
|
try:
|
|
self.run_as_current_user(self.fs.rmdir, path)
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.respond("250 Directory removed.")
|
|
|
|
def ftp_DELE(self, path):
|
|
try:
|
|
self.run_as_current_user(self.fs.remove, path)
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.respond("250 File removed.")
|
|
return path
|
|
|
|
def ftp_RNFR(self, path):
|
|
if not self.fs.lexists(path):
|
|
self.respond("550 No such file or directory.")
|
|
elif self.fs.realpath(path) == self.fs.realpath(self.fs.root):
|
|
self.respond("550 Can't rename home directory.")
|
|
else:
|
|
self._rnfr = path
|
|
self.respond("350 Ready for destination name.")
|
|
|
|
def ftp_RNTO(self, path):
|
|
if not self._rnfr:
|
|
self.respond("503 Bad sequence of commands: use RNFR first.")
|
|
return
|
|
src = self._rnfr
|
|
self._rnfr = None
|
|
try:
|
|
self.run_as_current_user(self.fs.rename, src, path)
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.respond("250 Renaming ok.")
|
|
return (src, path)
|
|
|
|
def ftp_TYPE(self, line):
|
|
type = line.upper().replace(' ', '')
|
|
if type in ("A", "L7"):
|
|
self.respond("200 Type set to: ASCII.")
|
|
self._current_type = 'a'
|
|
elif type in ("I", "L8"):
|
|
self.respond("200 Type set to: Binary.")
|
|
self._current_type = 'i'
|
|
else:
|
|
self.respond(f'504 Unsupported type "{line}".')
|
|
|
|
def ftp_STRU(self, line):
|
|
stru = line.upper()
|
|
if stru == 'F':
|
|
self.respond('200 File transfer structure set to: F.')
|
|
elif stru in ('P', 'R'):
|
|
self.respond('504 Unimplemented STRU type.')
|
|
else:
|
|
self.respond('501 Unrecognized STRU type.')
|
|
|
|
def ftp_MODE(self, line):
|
|
mode = line.upper()
|
|
if mode == 'S':
|
|
self.respond('200 Transfer mode set to: S')
|
|
elif mode in ('B', 'C'):
|
|
self.respond('504 Unimplemented MODE type.')
|
|
else:
|
|
self.respond('501 Unrecognized MODE type.')
|
|
|
|
def ftp_STAT(self, path):
|
|
if not path:
|
|
s = []
|
|
s.append(
|
|
'Connected to: %s:%s' % self.socket.getsockname()[:2]
|
|
)
|
|
if self.authenticated:
|
|
s.append(f'Logged in as: {self.username}')
|
|
elif not self.username:
|
|
s.append("Waiting for username.")
|
|
else:
|
|
s.append("Waiting for password.")
|
|
type = 'ASCII' if self._current_type == 'a' else 'Binary'
|
|
s.append(f"TYPE: {type}; STRUcture: File; MODE: Stream")
|
|
if self._dtp_acceptor is not None:
|
|
s.append('Passive data channel waiting for connection.')
|
|
elif self.data_channel is not None:
|
|
bytes_sent = self.data_channel.tot_bytes_sent
|
|
bytes_recv = self.data_channel.tot_bytes_received
|
|
elapsed_time = self.data_channel.get_elapsed_time()
|
|
s.extend((
|
|
'Data connection open:',
|
|
f'Total bytes sent: {bytes_sent}',
|
|
f'Total bytes received: {bytes_recv}',
|
|
f'Transfer elapsed time: {elapsed_time} secs',
|
|
))
|
|
else:
|
|
s.append('Data connection closed.')
|
|
self.push('211-FTP server status:\r\n')
|
|
self.push(''.join([f' {item}\r\n' for item in s]))
|
|
self.respond('211 End of status.')
|
|
else:
|
|
line = self.fs.fs2ftp(path)
|
|
try:
|
|
isdir = self.fs.isdir(path)
|
|
if isdir:
|
|
listing = self.run_as_current_user(self.fs.listdir, path)
|
|
if isinstance(listing, list):
|
|
listing.sort()
|
|
iterator = self.fs.format_list(path, listing)
|
|
else:
|
|
basedir, filename = os.path.split(path)
|
|
self.fs.lstat(path)
|
|
iterator = self.fs.format_list(basedir, [filename])
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.push(f'213-Status of "{line}":\r\n')
|
|
self.push_with_producer(BufferedIteratorProducer(iterator))
|
|
self.respond('213 End of status.')
|
|
return path
|
|
|
|
def ftp_FEAT(self, line):
|
|
features = {'TVFS', "GBK"}
|
|
if self.encoding.lower() in ("utf8", "utf-8"):
|
|
features.add('UTF8')
|
|
if self.encoding.lower() in ("gbk"):
|
|
features.add("GBK")
|
|
features.update([
|
|
feat
|
|
for feat in ('EPRT', 'EPSV', 'MDTM', 'MFMT', 'SIZE')
|
|
if feat in self.proto_cmds
|
|
])
|
|
features.update(self._extra_feats)
|
|
if 'MLST' in self.proto_cmds or 'MLSD' in self.proto_cmds:
|
|
facts = ''
|
|
for fact in self._available_facts:
|
|
if fact in self._current_facts:
|
|
facts += fact + '*;'
|
|
else:
|
|
facts += fact + ';'
|
|
features.add('MLST ' + facts)
|
|
if 'REST' in self.proto_cmds:
|
|
features.add('REST STREAM')
|
|
features = sorted(features)
|
|
self.push("211-Features supported:\r\n")
|
|
self.push("".join([f" {x}\r\n" for x in features]))
|
|
self.respond('211 End FEAT.')
|
|
|
|
def ftp_OPTS(self, line):
|
|
try:
|
|
if line.count(' ') > 1:
|
|
raise ValueError('Invalid number of arguments')
|
|
if ' ' in line:
|
|
cmd, arg = line.split(' ')
|
|
if ';' not in arg:
|
|
raise ValueError('Invalid argument')
|
|
else:
|
|
cmd, arg = line, ''
|
|
if cmd.upper() != 'MLST' or 'MLST' not in self.proto_cmds:
|
|
raise ValueError(f'Unsupported command "{cmd}"')
|
|
except ValueError as err:
|
|
self.respond(f'501 {err}.')
|
|
else:
|
|
facts = [x.lower() for x in arg.split(';')]
|
|
self._current_facts = [
|
|
x for x in facts if x in self._available_facts
|
|
]
|
|
f = ''.join([x + ';' for x in self._current_facts])
|
|
self.respond('200 MLST OPTS ' + f)
|
|
|
|
def ftp_NOOP(self, line):
|
|
self.respond("200 I successfully did nothing'.")
|
|
|
|
def ftp_SYST(self, line):
|
|
self.respond("215 UNIX Type: L8")
|
|
|
|
def ftp_ALLO(self, line):
|
|
self.respond("202 No storage allocation necessary.")
|
|
|
|
def ftp_HELP(self, line):
|
|
if line:
|
|
line = line.upper()
|
|
if line in self.proto_cmds:
|
|
self.respond(f"214 {self.proto_cmds[line]['help']}")
|
|
else:
|
|
self.respond("501 Unrecognized command.")
|
|
else:
|
|
def formatted_help():
|
|
cmds = []
|
|
keys = sorted(
|
|
[x for x in self.proto_cmds if not x.startswith('SITE ')]
|
|
)
|
|
while keys:
|
|
elems = tuple(keys[0:8])
|
|
cmds.append(' %-6s' * len(elems) % elems + '\r\n')
|
|
del keys[0:8]
|
|
return ''.join(cmds)
|
|
|
|
self.push("214-The following commands are recognized:\r\n")
|
|
self.push(formatted_help())
|
|
self.respond("214 Help command successful.")
|
|
|
|
|
|
def ftp_SITE_CHMOD(self, path, mode):
|
|
try:
|
|
assert len(mode) in (3, 4)
|
|
for x in mode:
|
|
assert 0 <= int(x) <= 7
|
|
mode = int(mode, 8)
|
|
except (AssertionError, ValueError):
|
|
self.respond("501 Invalid SITE CHMOD format.")
|
|
else:
|
|
try:
|
|
self.run_as_current_user(self.fs.chmod, path, mode)
|
|
except (OSError, FilesystemError) as err:
|
|
why = _strerror(err)
|
|
self.respond(f'550 {why}.')
|
|
else:
|
|
self.respond('200 SITE CHMOD successful.')
|
|
return (path, mode)
|
|
|
|
def ftp_SITE_HELP(self, line):
|
|
if line:
|
|
line = line.upper()
|
|
if line in self.proto_cmds:
|
|
self.respond(f"214 {self.proto_cmds[line]['help']}")
|
|
else:
|
|
self.respond("501 Unrecognized SITE command.")
|
|
else:
|
|
self.push("214-The following SITE commands are recognized:\r\n")
|
|
site_cmds = []
|
|
for cmd in sorted(self.proto_cmds.keys()):
|
|
if cmd.startswith('SITE '):
|
|
site_cmds.append(f' {cmd[5:]}\r\n')
|
|
self.push(''.join(site_cmds))
|
|
self.respond("214 Help SITE command successful.")
|
|
|
|
def ftp_XCUP(self, line):
|
|
return self.ftp_CDUP(line)
|
|
|
|
def ftp_XCWD(self, line):
|
|
return self.ftp_CWD(line)
|
|
|
|
def ftp_XMKD(self, line):
|
|
return self.ftp_MKD(line)
|
|
|
|
def ftp_XPWD(self, line):
|
|
return self.ftp_PWD(line)
|
|
|
|
def ftp_XRMD(self, line):
|
|
return self.ftp_RMD(line)
|
|
|
|
|
|
|
|
if SSL is not None:
|
|
|
|
class SSLConnection:
|
|
_ssl_accepting = False
|
|
_ssl_established = False
|
|
_ssl_closing = False
|
|
_ssl_requested = False
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._error = False
|
|
self._ssl_want_read = False
|
|
self._ssl_want_write = False
|
|
|
|
def readable(self):
|
|
return (
|
|
self._ssl_accepting
|
|
or self._ssl_want_read
|
|
or super().readable()
|
|
)
|
|
|
|
def writable(self):
|
|
return self._ssl_want_write or super().writable()
|
|
|
|
def secure_connection(self, ssl_context):
|
|
debug("securing SSL connection", self)
|
|
self._ssl_requested = True
|
|
try:
|
|
self.socket = SSL.Connection(ssl_context, self.socket)
|
|
except OSError as err:
|
|
debug(
|
|
"call: secure_connection(); can't secure SSL connection "
|
|
f"{err!r}; closing",
|
|
self,
|
|
)
|
|
self.close()
|
|
except ValueError:
|
|
if self.socket.fileno() == -1:
|
|
debug(
|
|
"ValueError and fd == -1 on secure_connection()", self
|
|
)
|
|
return
|
|
raise
|
|
else:
|
|
self.socket.set_accept_state()
|
|
self._ssl_accepting = True
|
|
|
|
@contextlib.contextmanager
|
|
def _handle_ssl_want_rw(self):
|
|
prev_row_pending = self._ssl_want_read or self._ssl_want_write
|
|
try:
|
|
yield
|
|
except SSL.WantReadError:
|
|
self._ssl_want_read = True
|
|
except SSL.WantWriteError:
|
|
self._ssl_want_write = True
|
|
|
|
if self._ssl_want_read:
|
|
self.modify_ioloop_events(
|
|
self._wanted_io_events | self.ioloop.READ, logdebug=True
|
|
)
|
|
elif self._ssl_want_write:
|
|
self.modify_ioloop_events(
|
|
self._wanted_io_events | self.ioloop.WRITE, logdebug=True
|
|
)
|
|
elif prev_row_pending:
|
|
self.modify_ioloop_events(self._wanted_io_events)
|
|
|
|
def _do_ssl_handshake(self):
|
|
self._ssl_accepting = True
|
|
self._ssl_want_read = False
|
|
self._ssl_want_write = False
|
|
try:
|
|
self.socket.do_handshake()
|
|
except SSL.WantReadError:
|
|
self._ssl_want_read = True
|
|
debug("call: _do_ssl_handshake, err: ssl-want-read", inst=self)
|
|
except SSL.WantWriteError:
|
|
self._ssl_want_write = True
|
|
debug(
|
|
"call: _do_ssl_handshake, err: ssl-want-write", inst=self
|
|
)
|
|
except SSL.SysCallError as err:
|
|
debug(f"call: _do_ssl_handshake, err: {err!r}", inst=self)
|
|
retval, desc = err.args
|
|
if (retval == -1 and desc == 'Unexpected EOF') or retval > 0:
|
|
self.log("Unexpected SSL EOF.")
|
|
self.close()
|
|
else:
|
|
raise
|
|
except SSL.Error as err:
|
|
debug(f"call: _do_ssl_handshake, err: {err!r}", inst=self)
|
|
self.handle_failed_ssl_handshake()
|
|
else:
|
|
debug("SSL connection established", self)
|
|
self._ssl_accepting = False
|
|
self._ssl_established = True
|
|
self.handle_ssl_established()
|
|
|
|
def handle_ssl_established(self):
|
|
pass
|
|
|
|
def handle_ssl_shutdown(self):
|
|
super().close()
|
|
|
|
def handle_failed_ssl_handshake(self):
|
|
raise NotImplementedError("must be implemented in subclass")
|
|
|
|
def handle_read_event(self):
|
|
if not self._ssl_requested:
|
|
super().handle_read_event()
|
|
else:
|
|
with self._handle_ssl_want_rw():
|
|
self._ssl_want_read = False
|
|
if self._ssl_accepting:
|
|
self._do_ssl_handshake()
|
|
elif self._ssl_closing:
|
|
self._do_ssl_shutdown()
|
|
else:
|
|
super().handle_read_event()
|
|
|
|
def handle_write_event(self):
|
|
if not self._ssl_requested:
|
|
super().handle_write_event()
|
|
else:
|
|
with self._handle_ssl_want_rw():
|
|
self._ssl_want_write = False
|
|
if self._ssl_accepting:
|
|
self._do_ssl_handshake()
|
|
elif self._ssl_closing:
|
|
self._do_ssl_shutdown()
|
|
else:
|
|
super().handle_write_event()
|
|
|
|
def handle_error(self):
|
|
self._error = True
|
|
try:
|
|
raise
|
|
except Exception:
|
|
self.log_exception(self)
|
|
try:
|
|
super().close()
|
|
except Exception:
|
|
logger.critical(traceback.format_exc())
|
|
|
|
def send(self, data):
|
|
if not isinstance(data, bytes):
|
|
data = bytes(data)
|
|
try:
|
|
return super().send(data)
|
|
except SSL.WantReadError:
|
|
debug("call: send(), err: ssl-want-read", inst=self)
|
|
self._ssl_want_read = True
|
|
return 0
|
|
except SSL.WantWriteError:
|
|
debug("call: send(), err: ssl-want-write", inst=self)
|
|
self._ssl_want_write = True
|
|
return 0
|
|
except SSL.ZeroReturnError:
|
|
debug(
|
|
"call: send() -> shutdown(), err: zero-return", inst=self
|
|
)
|
|
super().handle_close()
|
|
return 0
|
|
except SSL.SysCallError as err:
|
|
debug(f"call: send(), err: {err!r}", inst=self)
|
|
errnum, errstr = err.args
|
|
if errnum == errno.EWOULDBLOCK:
|
|
return 0
|
|
elif (
|
|
errnum in _ERRNOS_DISCONNECTED
|
|
or errstr == 'Unexpected EOF'
|
|
):
|
|
super().handle_close()
|
|
return 0
|
|
else:
|
|
raise
|
|
|
|
def recv(self, buffer_size):
|
|
try:
|
|
return super().recv(buffer_size)
|
|
except SSL.WantReadError:
|
|
debug("call: recv(), err: ssl-want-read", inst=self)
|
|
self._ssl_want_read = True
|
|
raise RetryError
|
|
except SSL.WantWriteError:
|
|
debug("call: recv(), err: ssl-want-write", inst=self)
|
|
self._ssl_want_write = True
|
|
raise RetryError
|
|
except SSL.ZeroReturnError:
|
|
debug(
|
|
"call: recv() -> shutdown(), err: zero-return", inst=self
|
|
)
|
|
super().handle_close()
|
|
return b''
|
|
except SSL.SysCallError as err:
|
|
debug(f"call: recv(), err: {err!r}", inst=self)
|
|
errnum, errstr = err.args
|
|
if (
|
|
errnum in _ERRNOS_DISCONNECTED
|
|
or errstr == 'Unexpected EOF'
|
|
):
|
|
super().handle_close()
|
|
return b''
|
|
else:
|
|
raise
|
|
|
|
def _do_ssl_shutdown(self):
|
|
self._ssl_closing = True
|
|
if os.name == 'posix':
|
|
try:
|
|
os.write(self.socket.fileno(), b'')
|
|
except OSError as err:
|
|
debug(
|
|
f"call: _do_ssl_shutdown() -> os.write, err: {err!r}",
|
|
inst=self,
|
|
)
|
|
if err.errno in {
|
|
errno.EINTR,
|
|
errno.EWOULDBLOCK,
|
|
errno.ENOBUFS,
|
|
}:
|
|
return
|
|
elif err.errno in _ERRNOS_DISCONNECTED:
|
|
return super().close()
|
|
else:
|
|
raise
|
|
try:
|
|
laststate = self.socket.get_shutdown()
|
|
self.socket.set_shutdown(laststate | SSL.RECEIVED_SHUTDOWN)
|
|
done = self.socket.shutdown()
|
|
if not laststate & SSL.RECEIVED_SHUTDOWN:
|
|
self.socket.set_shutdown(SSL.SENT_SHUTDOWN)
|
|
except SSL.WantReadError:
|
|
self._ssl_want_read = True
|
|
debug("call: _do_ssl_shutdown, err: ssl-want-read", inst=self)
|
|
except SSL.WantWriteError:
|
|
self._ssl_want_write = True
|
|
debug("call: _do_ssl_shutdown, err: ssl-want-write", inst=self)
|
|
except SSL.ZeroReturnError:
|
|
debug(
|
|
"call: _do_ssl_shutdown() -> shutdown(), err: zero-return",
|
|
inst=self,
|
|
)
|
|
super().close()
|
|
except SSL.SysCallError as err:
|
|
debug(
|
|
f"call: _do_ssl_shutdown() -> shutdown(), err: {err!r}",
|
|
inst=self,
|
|
)
|
|
errnum, errstr = err.args
|
|
if (
|
|
errnum in _ERRNOS_DISCONNECTED
|
|
or errstr == 'Unexpected EOF'
|
|
):
|
|
super().close()
|
|
else:
|
|
raise
|
|
except SSL.Error as err:
|
|
debug(
|
|
f"call: _do_ssl_shutdown() -> shutdown(), err: {err!r}",
|
|
inst=self,
|
|
)
|
|
if err.args and not getattr(err, "errno", None):
|
|
pass
|
|
else:
|
|
raise
|
|
except OSError as err:
|
|
debug(
|
|
f"call: _do_ssl_shutdown() -> shutdown(), err: {err!r}",
|
|
inst=self,
|
|
)
|
|
if err.errno in _ERRNOS_DISCONNECTED:
|
|
super().close()
|
|
else:
|
|
raise
|
|
else:
|
|
if done:
|
|
debug(
|
|
"call: _do_ssl_shutdown(), shutdown completed",
|
|
inst=self,
|
|
)
|
|
self._ssl_established = False
|
|
self._ssl_closing = False
|
|
self.handle_ssl_shutdown()
|
|
else:
|
|
debug(
|
|
"call: _do_ssl_shutdown(), shutdown not completed yet",
|
|
inst=self,
|
|
)
|
|
|
|
def close(self):
|
|
if self._ssl_established and not self._error:
|
|
self._do_ssl_shutdown()
|
|
else:
|
|
self._ssl_accepting = False
|
|
self._ssl_established = False
|
|
self._ssl_closing = False
|
|
super().close()
|
|
|
|
class TLS_DTPHandler(SSLConnection, DTPHandler):
|
|
def __init__(self, sock, cmd_channel):
|
|
super().__init__(sock, cmd_channel)
|
|
if self.cmd_channel._prot:
|
|
self.secure_connection(self.cmd_channel.ssl_context)
|
|
|
|
def __repr__(self):
|
|
return DTPHandler.__repr__(self)
|
|
|
|
def use_sendfile(self):
|
|
if isinstance(self.socket, SSL.Connection):
|
|
return False
|
|
else:
|
|
return super().use_sendfile()
|
|
|
|
def handle_failed_ssl_handshake(self):
|
|
self.cmd_channel.respond("522 SSL handshake failed.")
|
|
self.cmd_channel.log_cmd("PROT", "P", 522, "SSL handshake failed.")
|
|
self.close()
|
|
|
|
class TLS_FTPHandler(SSLConnection, FTPHandler):
|
|
tls_control_required = False
|
|
tls_data_required = False
|
|
certfile = None
|
|
keyfile = None
|
|
ssl_protocol = SSL.TLS_SERVER_METHOD
|
|
ssl_options = SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3
|
|
if hasattr(SSL, "OP_NO_COMPRESSION"):
|
|
ssl_options |= SSL.OP_NO_COMPRESSION
|
|
ssl_context = None
|
|
dtp_handler = TLS_DTPHandler
|
|
proto_cmds = FTPHandler.proto_cmds.copy()
|
|
proto_cmds.update({
|
|
'AUTH': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=True,
|
|
help=(
|
|
'Syntax: AUTH <SP> TLS|SSL (set up secure control '
|
|
'channel).'
|
|
),
|
|
),
|
|
'PBSZ': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=True,
|
|
help='Syntax: PBSZ <SP> 0 (negotiate TLS buffer).',
|
|
),
|
|
'PROT': dict(
|
|
perm=None,
|
|
auth=False,
|
|
arg=True,
|
|
help=(
|
|
'Syntax: PROT <SP> [C|P] (set up un/secure data channel).'
|
|
),
|
|
),
|
|
})
|
|
|
|
def __init__(self, conn, server, ioloop=None):
|
|
super().__init__(conn, server, ioloop)
|
|
if not self.connected:
|
|
return
|
|
self._extra_feats = ['AUTH TLS', 'AUTH SSL', 'PBSZ', 'PROT']
|
|
self._pbsz = False
|
|
self._prot = False
|
|
self.ssl_context = self.get_ssl_context()
|
|
|
|
def __repr__(self):
|
|
return FTPHandler.__repr__(self)
|
|
|
|
@classmethod
|
|
def get_ssl_context(cls):
|
|
if cls.ssl_context is None:
|
|
if cls.certfile is None:
|
|
raise ValueError("at least certfile must be specified")
|
|
cls.ssl_context = SSL.Context(cls.ssl_protocol)
|
|
cls.ssl_context.use_certificate_chain_file(cls.certfile)
|
|
if not cls.keyfile:
|
|
cls.keyfile = cls.certfile
|
|
cls.ssl_context.use_privatekey_file(cls.keyfile)
|
|
if cls.ssl_options:
|
|
cls.ssl_context.set_options(cls.ssl_options)
|
|
return cls.ssl_context
|
|
|
|
def flush_account(self):
|
|
FTPHandler.flush_account(self)
|
|
self._pbsz = False
|
|
self._prot = False
|
|
|
|
def process_command(self, cmd, *args, **kwargs):
|
|
if cmd in ('USER', 'PASS'):
|
|
if self.tls_control_required and not self._ssl_established:
|
|
msg = "SSL/TLS required on the control channel."
|
|
self.respond("550 " + msg)
|
|
self.log_cmd(cmd, args[0], 550, msg)
|
|
return
|
|
elif cmd in ('PASV', 'EPSV', 'PORT', 'EPRT'):
|
|
if self.tls_data_required and not self._prot:
|
|
msg = "SSL/TLS required on the data channel."
|
|
self.respond("550 " + msg)
|
|
self.log_cmd(cmd, args[0], 550, msg)
|
|
return
|
|
FTPHandler.process_command(self, cmd, *args, **kwargs)
|
|
|
|
def close(self):
|
|
SSLConnection.close(self)
|
|
FTPHandler.close(self)
|
|
|
|
def handle_failed_ssl_handshake(self):
|
|
self.log("SSL handshake failed.")
|
|
self.close()
|
|
|
|
def ftp_AUTH(self, line):
|
|
arg = line.upper()
|
|
if isinstance(self.socket, SSL.Connection):
|
|
self.respond("503 Already using TLS.")
|
|
elif arg in ('TLS', 'TLS-C', 'SSL', 'TLS-P'):
|
|
self.respond(f'234 AUTH {arg} successful.')
|
|
self.secure_connection(self.ssl_context)
|
|
else:
|
|
self.respond(
|
|
"502 Unrecognized encryption type (use TLS or SSL)."
|
|
)
|
|
|
|
def ftp_PBSZ(self, line):
|
|
if not isinstance(self.socket, SSL.Connection):
|
|
self.respond(
|
|
"503 PBSZ not allowed on insecure control connection."
|
|
)
|
|
else:
|
|
self.respond('200 PBSZ=0 successful.')
|
|
self._pbsz = True
|
|
|
|
def ftp_PROT(self, line):
|
|
arg = line.upper()
|
|
if not isinstance(self.socket, SSL.Connection):
|
|
self.respond(
|
|
"503 PROT not allowed on insecure control connection."
|
|
)
|
|
elif not self._pbsz:
|
|
self.respond(
|
|
"503 You must issue the PBSZ command prior to PROT."
|
|
)
|
|
elif arg == 'C':
|
|
self.respond('200 Protection set to Clear')
|
|
self._prot = False
|
|
elif arg == 'P':
|
|
self.respond('200 Protection set to Private')
|
|
self._prot = True
|
|
elif arg in ('S', 'E'):
|
|
self.respond(f'521 PROT {arg} unsupported (use C or P).')
|
|
else:
|
|
self.respond("502 Unrecognized PROT type (use C or P).")
|