Repository: twisted/twisted

Issue:
twisted.protocols.ftp.FTP should support IPv6 extensions
|[<img alt="cjwatson's avatar" src="https://avatars.githubusercontent.com/u/2429141?s=50" width="50" height="50">](https://github.com/cjwatson)| @cjwatson reported|
|-|-|
|Trac ID|trac#9645|
|Type|enhancement|
|Created|2019-05-31 13:52:32Z|

RFC 2428 defines FTP extensions needed to make it work over IPv6, which Twisted doesn't yet support.  This [blocks ppa.launchpad.net being available over IPv6](https://bugs.launchpad.net/txpkgupload/+bug/1807662) (well, we'll probably monkey-patch support rather than actually waiting for a Twisted release, but we still need to do most of the same work).

This is related to [#9085](https://github.com/twisted/twisted/issues/9085), but that's the client side whereas this is the server side.

<details><summary>Searchable metadata</summary>

```
trac-id__9645 9645
type__enhancement enhancement
reporter__cjwatson cjwatson
priority__normal normal
milestone__None None
branch__ 
branch_author__ 
status__new new
resolution__None None
component__ftp ftp
keywords__None None
time__1559310752044062 1559310752044062
changetime__1570997590451628 1570997590451628
version__None None
owner__cjwatson cjwatson

```
</details>



Discussion:

Resolving patch:
diff --git a/src/twisted/protocols/ftp.py b/src/twisted/protocols/ftp.py
--- a/src/twisted/protocols/ftp.py
+++ b/src/twisted/protocols/ftp.py
@@ -6,10 +6,10 @@
 An FTP protocol implementation
 """
 
+# System Imports
 import errno
 import fnmatch
-
-# System Imports
+import ipaddress
 import os
 import re
 import stat
@@ -41,6 +41,7 @@
 CMD_OK = "200.1"
 TYPE_SET_OK = "200.2"
 ENTERING_PORT_MODE = "200.3"
+EPSV_ALL_OK = "200.4"
 CMD_NOT_IMPLMNTD_SUPERFLUOUS = "202"
 SYS_STATUS_OR_HELP_REPLY = "211.1"
 FEAT_OK = "211.2"
@@ -80,8 +81,10 @@
 SYNTAX_ERR_IN_ARGS = "501"
 CMD_NOT_IMPLMNTD = "502.1"
 OPTS_NOT_IMPLEMENTED = "502.2"
+PASV_IPV6_NOT_IMPLEMENTED = "502.3"
 BAD_CMD_SEQ = "503"
 CMD_NOT_IMPLMNTD_FOR_PARAM = "504"
+UNSUPPORTED_NETWORK_PROTOCOL = "522"
 NOT_LOGGED_IN = "530.1"  # v1 of code 530 - please log in
 AUTH_FAILURE = "530.2"  # v2 of code 530 - authorization failure
 NEED_ACCT_FOR_STOR = "532"
@@ -110,6 +113,7 @@
     CMD_OK: "200 Command OK",
     TYPE_SET_OK: "200 Type set to %s.",
     ENTERING_PORT_MODE: "200 PORT OK",
+    EPSV_ALL_OK: "200 EPSV ALL OK",
     CMD_NOT_IMPLMNTD_SUPERFLUOUS: "202 Command not implemented, "
     "superfluous at this site",
     SYS_STATUS_OR_HELP_REPLY: "211 System status reply",
@@ -127,8 +131,8 @@
     CLOSING_DATA_CNX: "226 Abort successful",
     TXFR_COMPLETE_OK: "226 Transfer Complete.",
     ENTERING_PASV_MODE: "227 Entering Passive Mode (%s).",
-    # Where is EPSV defined in the RFCs?
-    ENTERING_EPSV_MODE: "229 Entering Extended Passive Mode " "(|||%s|).",
+    # RFC 2428 section 3
+    ENTERING_EPSV_MODE: "229 Entering Extended Passive Mode (|||%s|).",
     USR_LOGGED_IN_PROCEED: "230 User logged in, proceed",
     GUEST_LOGGED_IN_PROCEED: "230 Anonymous login ok, access " "restrictions apply.",
     # i.e. CWD completed OK
@@ -159,8 +163,11 @@
     SYNTAX_ERR_IN_ARGS: "501 syntax error in argument(s) %s.",
     CMD_NOT_IMPLMNTD: "502 Command '%s' not implemented",
     OPTS_NOT_IMPLEMENTED: "502 Option '%s' not implemented.",
+    PASV_IPV6_NOT_IMPLEMENTED: "502 PASV available only for IPv4 (use EPSV instead)",
     BAD_CMD_SEQ: "503 Incorrect sequence of commands: " "%s",
     CMD_NOT_IMPLMNTD_FOR_PARAM: "504 Not implemented for parameter " "'%s'.",
+    # RFC 2428 section 2
+    UNSUPPORTED_NETWORK_PROTOCOL: "522 Network protocol not supported, use (%s)",
     NOT_LOGGED_IN: "530 Please login with USER and PASS.",
     AUTH_FAILURE: "530 Sorry, Authentication failed.",
     NEED_ACCT_FOR_STOR: "532 Need an account for storing " "files",
@@ -178,6 +185,16 @@
 }
 
 
+# IANA address family numbers
+# (https://www.iana.org/assignments/address-family-numbers).
+# We only handle IP and IP6 at the moment.
+#
+# If these are needed by other parts of Twisted then they should be moved
+# somewhere more central, filled out, and exported.
+_AFNUM_IP = 1
+_AFNUM_IP6 = 2
+
+
 class InvalidPath(Exception):
     """
     Internal exception used to signify an error during parsing a path.
@@ -363,6 +380,14 @@ class CmdNotImplementedForArgError(FTPCmdError):
     errorCode = CMD_NOT_IMPLMNTD_FOR_PARAM
 
 
+class PASVIPv6NotImplementedError(FTPCmdError):
+    """
+    Raised when PASV is used with IPv6.
+    """
+
+    errorCode = PASV_IPV6_NOT_IMPLEMENTED
+
+
 class FTPError(Exception):
     pass
 
@@ -387,6 +412,14 @@ class AuthorizationError(FTPCmdError):
     errorCode = AUTH_FAILURE
 
 
+class UnsupportedNetworkProtocolError(FTPCmdError):
+    """
+    Raised when the client requests an unsupported network protocol.
+    """
+
+    errorCode = UNSUPPORTED_NETWORK_PROTOCOL
+
+
 def debugDeferred(self, *_):
     log.msg("debugDeferred(): %s" % str(_), debug=True)
 
@@ -718,6 +751,11 @@ class FTP(basic.LineReceiver, policies.TimeoutMixin):
     @ivar listenFactory: A callable with the signature of
         L{twisted.internet.interfaces.IReactorTCP.listenTCP} which will be used
         to create Ports for passive connections (mainly for testing).
+    @ivar _epsvAll: If true, "EPSV ALL" was received from the client,
+        requiring the server to reject all data connection setup commands
+        other than EPSV.  See RFC 2428.
+    @ivar _supportedNetworkProtocols: A collection of network protocol
+        numbers supported by the EPRT and EPSV commands.
 
     @ivar passivePortRange: iterator used as source of passive port numbers.
     @type passivePortRange: C{iterator}
@@ -749,6 +787,8 @@ class FTP(basic.LineReceiver, policies.TimeoutMixin):
     dtpPort = None
     dtpInstance = None
     binary = True
+    _epsvAll = False
+    _supportedNetworkProtocols = (_AFNUM_IP, _AFNUM_IP6)
     PUBLIC_COMMANDS = ["FEAT", "QUIT"]
     FEATURES = ["FEAT", "MDTM", "PASV", "SIZE", "TYPE A;I"]
 
@@ -786,6 +826,7 @@ def connectionLost(self, reason):
         if hasattr(self.shell, "logout") and self.shell.logout is not None:
             self.shell.logout()
         self.shell = None
+        self._epsvAll = False
         self.transport = None
 
     def timeoutConnection(self):
@@ -873,14 +914,21 @@ def call_ftp_command(command):
             else:
                 return BAD_CMD_SEQ, "RNTO required after RNFR"
 
-    def getDTPPort(self, factory):
+    def getDTPPort(self, factory, interface=""):
         """
         Return a port for passive access, using C{self.passivePortRange}
         attribute.
+
+        @param factory: the protocol factory to connect to the port.
+        @type factory: L{twisted.internet.protocol.ServerFactory}
+
+        @param interface: the local IPv4 or IPv6 address to which to bind;
+            defaults to "", i.e. all IPv4 addresses.
+        @type interface: C{str}
         """
         for portn in self.passivePortRange:
             try:
-                dtpPort = self.listenFactory(portn, factory)
+                dtpPort = self.listenFactory(portn, factory, interface=interface)
             except error.CannotListenError:
                 continue
             else:
@@ -952,6 +1000,28 @@ def ftp_PASV(self):
             response to this command includes the host and port address this
             server is listening on.
         """
+        if self._epsvAll:
+            return defer.fail(BadCmdSequenceError("may not send PASV after EPSV ALL"))
+
+        host = self.transport.getHost().host
+        try:
+            address = ipaddress.IPv6Address(host)
+        except ipaddress.AddressValueError:
+            pass
+        else:
+            if address.ipv4_mapped is not None:
+                # IPv4-mapped addresses are usable, but we need to make sure
+                # they're encoded as IPv4 in the response.
+                host = str(address.ipv4_mapped)
+            else:
+                # There's no standard defining the behaviour of PASV with
+                # IPv6, so just claim it as unimplemented.  (Some servers
+                # return something like '0,0,0,0' in the host part of the
+                # response in order that at least clients that ignore the
+                # host part can work, and if it becomes necessary then we
+                # could do that too.)
+                return defer.fail(PASVIPv6NotImplementedError())
+
         # if we have a DTP port set up, lose it.
         if self.dtpFactory is not None:
             # cleanupDTP sets dtpFactory to none.  Later we'll do
@@ -961,12 +1031,81 @@ def ftp_PASV(self):
         self.dtpFactory.setTimeout(self.dtpTimeout)
         self.dtpPort = self.getDTPPort(self.dtpFactory)
 
-        host = self.transport.getHost().host
         port = self.dtpPort.getHost().port
         self.reply(ENTERING_PASV_MODE, encodeHostPort(host, port))
         return self.dtpFactory.deferred.addCallback(lambda ign: None)
 
+    def _validateNetworkProtocol(self, protocol):
+        """
+        Validate the network protocol requested in an EPRT or EPSV command.
+
+        For now we just hardcode the protocols we support, since this layer
+        doesn't have a good way to discover that.
+
+        @param protocol: An address family number.  See RFC 2428 section 2.
+        @type protocol: L{str}
+
+        @raise FTPCmdError: If validation fails.
+        """
+        # We can't actually honour an explicit network protocol request
+        # (violating a SHOULD in RFC 2428 section 3), but let's at least
+        # validate it.
+        try:
+            protocol = int(protocol)
+        except ValueError:
+            raise CmdArgSyntaxError(protocol)
+        if protocol not in self._supportedNetworkProtocols:
+            raise UnsupportedNetworkProtocolError(
+                ",".join(str(p) for p in self._supportedNetworkProtocols)
+            )
+
+    def ftp_EPSV(self, protocol=""):
+        """
+        Extended request for a passive connection.
+
+        As described by U{RFC 2428 section
+        3<https://tools.ietf.org/html/rfc2428#section-3>}::
+
+            The EPSV command requests that a server listen on a data port
+            and wait for a connection.  The EPSV command takes an optional
+            argument.  The response to this command includes only the TCP
+            port number of the listening connection.  The format of the
+            response, however, is similar to the argument of the EPRT
+            command.  This allows the same parsing routines to be used for
+            both commands.  In addition, the format leaves a place holder
+            for the network protocol and/or network address, which may be
+            needed in the EPSV response in the future.
+        """
+        if protocol == "ALL":
+            self._epsvAll = True
+            return EPSV_ALL_OK
+        elif protocol:
+            try:
+                self._validateNetworkProtocol(protocol)
+            except FTPCmdError:
+                return defer.fail()
+
+        # if we have a DTP port set up, lose it.
+        if self.dtpFactory is not None:
+            # cleanupDTP sets dtpFactory to none.  Later we'll do
+            # cleanup here or something.
+            self.cleanupDTP()
+        self.dtpFactory = DTPFactory(pi=self)
+        self.dtpFactory.setTimeout(self.dtpTimeout)
+        if not protocol or protocol == _AFNUM_IP6:
+            interface = "::"
+        else:
+            interface = ""
+        self.dtpPort = self.getDTPPort(self.dtpFactory, interface=interface)
+
+        port = self.dtpPort.getHost().port
+        self.reply(ENTERING_EPSV_MODE, port)
+        return self.dtpFactory.deferred.addCallback(lambda ign: None)
+
     def ftp_PORT(self, address):
+        if self._epsvAll:
+            return defer.fail(BadCmdSequenceError("may not send PORT after EPSV ALL"))
+
         addr = tuple(map(int, address.split(",")))
         ip = "%d.%d.%d.%d" % tuple(addr[:4])
         port = addr[4] << 8 | addr[5]
@@ -988,6 +1127,48 @@ def connFailed(err):
 
         return self.dtpFactory.deferred.addCallbacks(connected, connFailed)
 
+    def ftp_EPRT(self, extendedAddress):
+        """
+        Extended request for a data connection.
+
+        As described by U{RFC 2428 section
+        2<https://tools.ietf.org/html/rfc2428#section-2>}::
+
+            The EPRT command allows for the specification of an extended
+            address for the data connection.  The extended address MUST
+            consist of the network protocol as well as the network and
+            transport addresses.
+        """
+        if self._epsvAll:
+            return defer.fail(BadCmdSequenceError("may not send EPRT after EPSV ALL"))
+
+        try:
+            protocol, ip, port = decodeExtendedAddress(extendedAddress)
+        except ValueError:
+            return defer.fail(CmdArgSyntaxError(extendedAddress))
+        if protocol:
+            try:
+                self._validateNetworkProtocol(protocol)
+            except FTPCmdError:
+                return defer.fail()
+
+        # if we have a DTP port set up, lose it.
+        if self.dtpFactory is not None:
+            self.cleanupDTP()
+
+        self.dtpFactory = DTPFactory(pi=self, peerHost=self.transport.getPeer().host)
+        self.dtpFactory.setTimeout(self.dtpTimeout)
+        self.dtpPort = reactor.connectTCP(ip, port, self.dtpFactory)
+
+        def connected(ignored):
+            return ENTERING_PORT_MODE
+
+        def connFailed(err):
+            err.trap(PortConnectionError)
+            return CANT_OPEN_DATA_CNX
+
+        return self.dtpFactory.deferred.addCallbacks(connected, connFailed)
+
     def _encodeName(self, name):
         """
         Encode C{name} to be sent over the wire.
@@ -1176,7 +1357,7 @@ def ftp_RETR(self, path):
         @return: a L{Deferred} which will be fired when the transfer is done.
         """
         if self.dtpInstance is None:
-            raise BadCmdSequenceError("PORT or PASV required before RETR")
+            raise BadCmdSequenceError("PORT, PASV, EPRT, or EPSV required before RETR")
 
         try:
             newsegs = toSegments(self.workingDirectory, path)
@@ -1250,7 +1431,7 @@ def ftp_STOR(self, path):
         pathname does not already exist.
         """
         if self.dtpInstance is None:
-            raise BadCmdSequenceError("PORT or PASV required before STOR")
+            raise BadCmdSequenceError("PORT, PASV, EPRT, or EPSV required before STOR")
 
         try:
             newsegs = toSegments(self.workingDirectory, path)
@@ -2421,6 +2602,18 @@ def encodeHostPort(host, port):
     return ",".join(numbers)
 
 
+def decodeExtendedAddress(address):
+    """
+    Decode an FTP protocol/address/port combination, using the syntax
+    defined in RFC 2428 section 2.
+
+    @return: a 3-tuple of (protocol, host, port).
+    """
+    delim = address[0]
+    protocol, host, port, _ = address[1:].split(delim)
+    return protocol, host, int(port)
+
+
 def _unwrapFirstError(failure):
     failure.trap(defer.FirstError)
     return failure.value.subFailure


Test patch:
diff --git a/src/twisted/conch/test/test_conch.py b/src/twisted/conch/test/test_conch.py
--- a/src/twisted/conch/test/test_conch.py
+++ b/src/twisted/conch/test/test_conch.py
@@ -7,7 +7,6 @@
 import subprocess
 import sys
 from itertools import count
-from unittest import skipIf
 
 from zope.interface import implementer
 
@@ -28,6 +27,7 @@
 from twisted.python.filepath import FilePath
 from twisted.python.procutils import which
 from twisted.python.reflect import requireModule
+from twisted.test.testutils import HAS_IPV6, skipWithoutIPv6
 from twisted.trial.unittest import SkipTest, TestCase
 
 try:
@@ -60,26 +60,6 @@ class ConchUser:  # type: ignore[no-redef]
     StdioInteractingSession = _StdioInteractingSession
 
 
-def _has_ipv6():
-    """Returns True if the system can bind an IPv6 address."""
-    sock = None
-    has_ipv6 = False
-
-    try:
-        sock = socket.socket(socket.AF_INET6)
-        sock.bind(("::1", 0))
-        has_ipv6 = True
-    except OSError:
-        pass
-
-    if sock:
-        sock.close()
-    return has_ipv6
-
-
-HAS_IPV6 = _has_ipv6()
-
-
 class FakeStdio:
     """
     A fake for testing L{twisted.conch.scripts.conch.SSHSession.eofReceived} and
@@ -711,7 +691,7 @@ class OpenSSHClientForwardingTests(ForwardingMixin, OpenSSHClientMixin, TestCase
     Connection forwarding tests run against the OpenSSL command line client.
     """
 
-    @skipIf(not HAS_IPV6, "Requires IPv6 support")
+    @skipWithoutIPv6
     def test_localToRemoteForwardingV6(self):
         """
         Forwarding of arbitrary IPv6 TCP connections via SSH.
diff --git a/src/twisted/internet/test/test_udp.py b/src/twisted/internet/test/test_udp.py
--- a/src/twisted/internet/test/test_udp.py
+++ b/src/twisted/internet/test/test_udp.py
@@ -27,35 +27,10 @@
 from twisted.python import context
 from twisted.python.log import ILogContext, err
 from twisted.test.test_udp import GoodClient, Server
+from twisted.test.testutils import skipWithoutIPv6
 from twisted.trial.unittest import SkipTest
 
 
-def _has_ipv6():
-    """Returns True if the system can bind an IPv6 address."""
-    sock = None
-    has_ipv6 = False
-
-    try:
-        sock = socket.socket(socket.AF_INET6)
-        sock.bind(("::1", 0))
-        has_ipv6 = True
-    except OSError:
-        pass
-
-    if sock:
-        sock.close()
-    return has_ipv6
-
-
-HAS_IPV6 = _has_ipv6()
-
-
-def skipWithoutIPv6(f):
-    if not HAS_IPV6:
-        f.skip = "Does not work on systems without IPv6 support."
-    return f
-
-
 class DatagramTransportTestsMixin(LogObserverMixin):
     """
     Mixin defining tests which apply to any port/datagram based transport.
diff --git a/src/twisted/test/test_ftp.py b/src/twisted/test/test_ftp.py
--- a/src/twisted/test/test_ftp.py
+++ b/src/twisted/test/test_ftp.py
@@ -9,6 +9,8 @@
 import getpass
 import os
 import random
+import re
+import socket
 import string
 from io import BytesIO
 
@@ -23,6 +25,7 @@
 from twisted.protocols import basic, ftp, loopback
 from twisted.python import failure, filepath, runtime
 from twisted.test import proto_helpers
+from twisted.test.testutils import HAS_IPV6, skipWithoutIPv6
 from twisted.trial.unittest import TestCase
 
 if not runtime.platform.isWindows():
@@ -31,6 +34,40 @@
     nonPOSIXSkip = "Cannot run on Windows"
 
 
+def _hasIPv4MappedAddresses():
+    """Returns True if the system supports IPv4-mapped IPv6 addresses."""
+    if not HAS_IPV6:
+        return False
+    server = None
+    client = None
+    has_ipv4_mapped_addresses = False
+
+    try:
+        server = socket.socket(socket.AF_INET6)
+        server.bind(("::", 0))
+        server.listen(1)
+        client = socket.socket(socket.AF_INET)
+        client.connect(("127.0.0.1", server.getsockname()[1]))
+        has_ipv4_mapped_addresses = True
+    except OSError:
+        pass
+
+    if client:
+        client.close()
+    if server:
+        server.close()
+    return has_ipv4_mapped_addresses
+
+
+HAS_IPV4_MAPPED_ADDRESSES = _hasIPv4MappedAddresses()
+
+
+def skipWithoutIPv4MappedAddresses(f):
+    if not HAS_IPV4_MAPPED_ADDRESSES:
+        f.skip = "Does not work on systems without IPv4-mapped IPv6 addresses."
+    return f
+
+
 class Dummy(basic.LineReceiver):
     logname = None
 
@@ -85,6 +122,7 @@ class FTPServerTestCase(TestCase):
 
     clientFactory = ftp.FTPClientBasic
     userAnonymous = "anonymous"
+    bindAddress = "127.0.0.1"
 
     def setUp(self):
         # Keep a list of the protocols created so we can make sure they all
@@ -112,7 +150,9 @@ def setUp(self):
         p.registerChecker(users_checker, credentials.IUsernamePassword)
 
         self.factory = ftp.FTPFactory(portal=p, userAnonymous=self.userAnonymous)
-        self.port = port = reactor.listenTCP(0, self.factory, interface="127.0.0.1")
+        self.port = port = reactor.listenTCP(
+            0, self.factory, interface=self.bindAddress
+        )
         self.addCleanup(port.stopListening)
 
         # Hook the server's buildProtocol to make the protocol instance
@@ -217,6 +257,17 @@ def _userLogin(self):
             chainDeferred=d,
         )
 
+    def decodeExtendedAddressLine(self, line):
+        """
+        Decode an FTP response specifying a protocol/address/port combination,
+        using the syntax defined in RFC 2428 sections 2 and 3.
+
+        @return: a 3-tuple of (protocol, host, port).
+        """
+        match = re.search(r"\((.*)\)", line)
+        self.assertIsNotNone(match, f"No extended address found in {line}")
+        return ftp.decodeExtendedAddress(match.group(1))
+
 
 class FTPAnonymousTests(FTPServerTestCase):
     """
@@ -332,7 +383,7 @@ def test_PASSBeforeUSER(self):
         """
         return self.assertCommandFailed(
             "PASS foo",
-            ["503 Incorrect sequence of commands: " "USER required before PASS"],
+            ["503 Incorrect sequence of commands: USER required before PASS"],
         )
 
     def test_NoParamsForUSER(self):
@@ -458,7 +509,7 @@ def test_RETRBeforePORT(self):
             "RETR foo",
             [
                 "503 Incorrect sequence of commands: "
-                "PORT or PASV required before RETR"
+                "PORT, PASV, EPRT, or EPSV required before RETR"
             ],
             chainDeferred=d,
         )
@@ -474,7 +525,7 @@ def test_STORBeforePORT(self):
             "STOR foo",
             [
                 "503 Incorrect sequence of commands: "
-                "PORT or PASV required before STOR"
+                "PORT, PASV, EPRT, or EPSV required before STOR"
             ],
             chainDeferred=d,
         )
@@ -531,6 +582,150 @@ def cb(responseLines):
         d.addCallback(lambda _: self.serverProtocol.transport.loseConnection())
         return d
 
+    @skipWithoutIPv6
+    def test_PASVDeniedIfIPv6(self):
+        """
+        The C{PASV} command is rejected for IPv6 clients.  Use C{EPSV}
+        instead.
+        """
+        port = reactor.listenTCP(0, self.factory, interface="::1")
+        self.addCleanup(port.stopListening)
+        portNum = port.getHost().port
+        clientCreator = protocol.ClientCreator(reactor, self.clientFactory)
+        d = clientCreator.connectTCP("::1", portNum)
+
+        def gotClient(client):
+            self.client = client
+            self.addCleanup(self.client.transport.loseConnection)
+
+        d.addCallback(gotClient)
+        d.addCallback(lambda _: self._anonymousLogin())
+        return self.assertCommandFailed(
+            "PASV",
+            ["502 PASV available only for IPv4 (use EPSV instead)"],
+            chainDeferred=d,
+        )
+
+    def test_EPSVALLBeforePASV(self):
+        """
+        When the client sends the command C{EPSV ALL}, a subsequent C{PASV}
+        command is rejected.
+        """
+        d = self._anonymousLogin()
+        self.assertCommandResponse("EPSV ALL", ["200 EPSV ALL OK"], chainDeferred=d)
+        self.assertCommandFailed(
+            "PASV",
+            ["503 Incorrect sequence of commands: may not send PASV after EPSV ALL"],
+            chainDeferred=d,
+        )
+        return d
+
+    def test_EPSV(self):
+        """
+        When the client sends the command C{EPSV}, the server responds with a
+        port, and is listening on that port.
+        """
+        d = self._anonymousLogin()
+        d.addCallback(lambda _: self.client.queueStringCommand("EPSV"))
+
+        def cb(responseLines):
+            """
+            Extract the port from the response, and verify the server is
+            listening on the port it claims to be.
+            """
+            _, _, port = self.decodeExtendedAddressLine(responseLines[-1][4:])
+            self.assertEqual(port, self.serverProtocol.dtpPort.getHost().port)
+
+        d.addCallback(cb)
+        d.addCallback(lambda _: self.serverProtocol.transport.loseConnection())
+        return d
+
+    def test_EPSVWithProtocol(self):
+        """
+        When the client sends the command C{EPSV} with a network protocol
+        number, the server responds with a port, and is listening on that
+        port.
+        """
+        d = self._anonymousLogin()
+        d.addCallback(lambda _: self.client.queueStringCommand("EPSV 2"))
+
+        def cb(responseLines):
+            """
+            Extract the port from the response, and verify the server is
+            listening on the port it claims to be.
+            """
+            _, _, port = self.decodeExtendedAddressLine(responseLines[-1][4:])
+            self.assertEqual(port, self.serverProtocol.dtpPort.getHost().port)
+
+        d.addCallback(cb)
+        d.addCallback(lambda _: self.serverProtocol.transport.loseConnection())
+        return d
+
+    def test_EPSVWithBadProtocol(self):
+        """
+        Sending EPSV with a bad protocol number returns a suitable error.
+        """
+        d = self._anonymousLogin()
+        self.assertCommandFailed(
+            "EPSV nonsense",
+            ["501 syntax error in argument(s) nonsense."],
+            chainDeferred=d,
+        )
+        self.assertCommandFailed(
+            "EPSV 65535",
+            ["522 Network protocol not supported, use (1,2)"],
+            chainDeferred=d,
+        )
+        return d
+
+    def test_EPSVALLBeforeEPSV(self):
+        """
+        The client may send the command C{EPSV ALL} before C{EPSV}.
+        """
+        d = self._anonymousLogin()
+        self.assertCommandResponse("EPSV ALL", ["200 EPSV ALL OK"], chainDeferred=d)
+        d.addCallback(lambda _: self.client.queueStringCommand("EPSV"))
+
+        def cb(responseLines):
+            """
+            Extract the port from the response, and verify the server is
+            listening on the port it claims to be.
+            """
+            _, _, port = self.decodeExtendedAddressLine(responseLines[-1][4:])
+            self.assertEqual(port, self.serverProtocol.dtpPort.getHost().port)
+
+        d.addCallback(cb)
+        d.addCallback(lambda _: self.serverProtocol.transport.loseConnection())
+        return d
+
+    def test_EPSVALLBeforePORT(self):
+        """
+        When the client sends the command C{EPSV ALL}, a subsequent C{PORT}
+        command is rejected.
+        """
+        d = self._anonymousLogin()
+        self.assertCommandResponse("EPSV ALL", ["200 EPSV ALL OK"], chainDeferred=d)
+        self.assertCommandFailed(
+            "PORT " + ftp.encodeHostPort("127.0.0.1", 0),
+            ["503 Incorrect sequence of commands: may not send PORT after EPSV ALL"],
+            chainDeferred=d,
+        )
+        return d
+
+    def test_EPSVALLBeforeEPRT(self):
+        """
+        When the client sends the command C{EPSV ALL}, a subsequent C{EPRT}
+        command is rejected.
+        """
+        d = self._anonymousLogin()
+        self.assertCommandResponse("EPSV ALL", ["200 EPSV ALL OK"], chainDeferred=d)
+        self.assertCommandFailed(
+            "EPRT |1|127.0.0.1|0|",
+            ["503 Incorrect sequence of commands: may not send EPRT after EPSV ALL"],
+            chainDeferred=d,
+        )
+        return d
+
     def test_SYST(self):
         """
         SYST command will always return UNIX Type: L8
@@ -590,7 +785,7 @@ def test_portRangeForwardError(self):
         C{listenFactory} should be raised to the caller of L{FTP.getDTPPort}.
         """
 
-        def listenFactory(portNumber, factory):
+        def listenFactory(portNumber, factory, **kwargs):
             raise RuntimeError()
 
         self.serverProtocol.listenFactory = listenFactory
@@ -607,7 +802,7 @@ def test_portRange(self):
         first successful result from L{FTP.listenFactory} should be returned.
         """
 
-        def listenFactory(portNumber, factory):
+        def listenFactory(portNumber, factory, **kwargs):
             if portNumber in (22032, 22033, 22034):
                 raise error.CannotListenError("localhost", portNumber, "error")
             return portNumber
@@ -896,6 +1091,13 @@ def _makeDataConnection(self, ignored=None):
         d = self.client.queueStringCommand("PASV")
 
         def gotPASV(responseLines):
+            # ftp.decodeHostPort is more lenient than this, but we want to
+            # be extra-careful to ensure that nothing odd sneaks into the
+            # response that might confuse connection-tracking
+            # implementations.
+            self.assertRegex(
+                responseLines[-1], r"^227 Entering Passive Mode \((?:\d+,){5}\d+\)\.$"
+            )
             host, port = ftp.decodeHostPort(responseLines[-1][4:])
             cc = protocol.ClientCreator(reactor, _BufferingProtocol)
             return cc.connectTCP("127.0.0.1", port)
@@ -1241,7 +1443,49 @@ def checkDownload(download):
         return d.addCallback(checkDownload)
 
 
+@skipWithoutIPv4MappedAddresses
+class FTPServerIPv4MappedPasvDataConnectionTests(FTPServerPasvDataConnectionTests):
+    """
+    The C{PASV} command doesn't work for IPv6 connections (that's why
+    C{EPSV} was invented).  However, it's possible to use it from an IPv4
+    client connecting to a dual-stack server using IPv4-mapped addresses, if
+    the system's IPv6 stack supports that.
+    """
+
+    bindAddress = "::"
+
+
+@skipWithoutIPv6
+class FTPServerEpsvDataConnectionTests(FTPServerPasvDataConnectionTests):
+    """
+    EPSV data connection.
+    """
+
+    def _makeDataConnection(self, ignored=None):
+        """
+        Establish a passive data connection (i.e. client connecting to
+        server).
+
+        @param ignored: ignored
+        @return: L{Deferred.addCallback}
+        """
+        d = self.client.queueStringCommand("EPSV")
+
+        def gotEPSV(responseLines):
+            _, _, port = self.decodeExtendedAddressLine(responseLines[-1][4:])
+            cc = protocol.ClientCreator(reactor, _BufferingProtocol)
+            return cc.connectTCP("::1", port)
+
+        return d.addCallback(gotEPSV)
+
+
 class FTPServerPortDataConnectionTests(FTPServerPasvDataConnectionTests):
+    """
+    Test the C{PORT} command.
+
+    This opens a data connection to a given address and port.
+    """
+
     def setUp(self):
         self.dataPorts = []
         return FTPServerPasvDataConnectionTests.setUp(self)
@@ -1269,7 +1513,8 @@ def tearDown(self):
         """
         Tear down the connection.
 
-        @return: L{defer.DeferredList}
+        @return: a L{defer.DeferredList} that fires when all open data ports
+            have been closed.
         """
         l = [defer.maybeDeferred(port.stopListening) for port in self.dataPorts]
         d = defer.maybeDeferred(FTPServerPasvDataConnectionTests.tearDown, self)
@@ -1278,13 +1523,15 @@ def tearDown(self):
 
     def test_PORTCannotConnect(self):
         """
-        Listen on a port, and immediately stop listening as a way to find a
-        port number that is definitely closed.
+        When C{PORT} is asked to connect to a closed port, it returns a
+        suitable error.
         """
         # Login
         d = self._anonymousLogin()
 
         def loggedIn(ignored):
+            # Listen on a port, and immediately stop listening as a way to
+            # find a port number that should be closed.
             port = reactor.listenTCP(0, protocol.Factory(), interface="127.0.0.1")
             portNum = port.getHost().port
             d = port.stopListening()
@@ -1323,6 +1570,111 @@ def checkDownload(download):
         return d.addCallback(checkDownload)
 
 
+class FTPServerEprtDataConnectionTests(FTPServerPasvDataConnectionTests):
+    """
+    Test the C{EPRT} command.
+
+    This opens a data connection to a given protocol, address, and port, in
+    a format compatible with both IPv4 and IPv6.
+    """
+
+    def setUp(self):
+        self.dataPorts = []
+        return FTPServerPasvDataConnectionTests.setUp(self)
+
+    def _makeDataConnection(self, ignored=None):
+        # Establish an active data connection (i.e. server connecting to
+        # client).
+        deferred = defer.Deferred()
+
+        class DataFactory(protocol.ServerFactory):
+            protocol = _BufferingProtocol
+
+            def buildProtocol(self, addr):
+                p = protocol.ServerFactory.buildProtocol(self, addr)
+                reactor.callLater(0, deferred.callback, p)
+                return p
+
+        dataPort = reactor.listenTCP(0, DataFactory(), interface="127.0.0.1")
+        self.dataPorts.append(dataPort)
+        cmd = "EPRT |1|127.0.0.1|%s|" % dataPort.getHost().port
+        self.client.queueStringCommand(cmd)
+        return deferred
+
+    def tearDown(self):
+        """
+        Tear down the connection.
+
+        @return: a L{defer.DeferredList} that fires when all open data ports
+            have been closed.
+        """
+        dl = [defer.maybeDeferred(port.stopListening) for port in self.dataPorts]
+        d = defer.maybeDeferred(FTPServerPasvDataConnectionTests.tearDown, self)
+        dl.append(d)
+        return defer.DeferredList(dl, fireOnOneErrback=True)
+
+    def test_EPRTCannotConnect(self):
+        """
+        When C{EPRT} is asked to connect to a closed port, it returns a
+        suitable error.
+        """
+        # Login
+        d = self._anonymousLogin()
+
+        def loggedIn(ignored):
+            # Listen on a port, and immediately stop listening as a way to
+            # find a port number that should be closed.
+            port = reactor.listenTCP(0, protocol.Factory(), interface="127.0.0.1")
+            portNum = port.getHost().port
+            d = port.stopListening()
+            d.addCallback(lambda _: portNum)
+            return d
+
+        d.addCallback(loggedIn)
+
+        # Tell the server to connect to that port with an EPRT command, and
+        # verify that it fails with the right error.
+        def gotPortNum(portNum):
+            return self.assertCommandFailed(
+                "EPRT |1|127.0.0.1|%s|" % portNum, ["425 Can't open data connection."]
+            )
+
+        return d.addCallback(gotPortNum)
+
+    def test_EPRTBadAddress(self):
+        """
+        C{EPRT} returns a syntax error if it can't decode the address.
+        """
+        d = self._anonymousLogin()
+        return self.assertCommandFailed(
+            "EPRT nonsense",
+            ["501 syntax error in argument(s) nonsense."],
+            chainDeferred=d,
+        )
+
+    def test_EPRTInvalidNetworkProtocol(self):
+        """
+        C{EPRT} returns a syntax error if the network protocol is ill-formed.
+        """
+        d = self._anonymousLogin()
+        return self.assertCommandFailed(
+            "EPRT |not-a-protocol|127.0.0.1|12345|",
+            ["501 syntax error in argument(s) not-a-protocol."],
+            chainDeferred=d,
+        )
+
+    def test_EPRTUnsupportedNetworkProtocol(self):
+        """
+        C{EPRT} returns an error if the network protocol is unsupported.
+        """
+        d = self._anonymousLogin()
+        return self.assertCommandFailed(
+            "EPRT |3|127.0.0.1|12345|",
+            ["522 Network protocol not supported, use (1,2)"],
+            chainDeferred=d,
+        )
+
+
 class DTPFactoryTests(TestCase):
     """
     Tests for L{ftp.DTPFactory}.
diff --git a/src/twisted/test/testutils.py b/src/twisted/test/testutils.py
--- a/src/twisted/test/testutils.py
+++ b/src/twisted/test/testutils.py
@@ -12,6 +12,7 @@
     their own test cases.
 """
 
+import socket
 from io import BytesIO
 from xml.dom import minidom as dom
 
@@ -159,3 +160,29 @@ def assertNormalEqualityImplementation(
         self.assertFalse(firstValueOne != _Equal())
         self.assertFalse(firstValueOne == _NotEqual())
         self.assertTrue(firstValueOne != _NotEqual())
+
+
+def _has_ipv6():
+    """Returns True if the system can bind an IPv6 address."""
+    sock = None
+    has_ipv6 = False
+
+    try:
+        sock = socket.socket(socket.AF_INET6)
+        sock.bind(("::1", 0))
+        has_ipv6 = True
+    except OSError:
+        pass
+
+    if sock:
+        sock.close()
+    return has_ipv6
+
+
+HAS_IPV6 = _has_ipv6()
+
+
+def skipWithoutIPv6(f):
+    if not HAS_IPV6:
+        f.skip = "Does not work on systems without IPv6 support."
+    return f