Python, FTPS and mis-configured servers

Today's project involves automatically uploading electrical metering data to an FTPS server (explicit FTP over TLS, otherwise knowns as ESFTP). Shouldn't be a problem, since Python supports FTPS out of the box. Only it doesn't work. Here's the code:

import ftplib
ftp = ftplib.FTP_TLS('host', 'user', 'password')
ftp.set_debuglevel(1)
ftp.cwd('directory')
with open('filename', 'rb') as f:
    ftp.storbinary('STOR filename', f)
ftp.quit()

After a successful initial connection, the data transfer connection fails:

*cmd* 'PASV'
*resp* '227 Entering Passive Mode (10,200,0,100,255,150).'
*cmd* 'CWD collect/CSV'
*resp* '250 CWD command successful'
*cmd* 'TYPE I'
*resp* '200 Type set to I'
*cmd* 'PASV'
*resp* '227 Entering Passive Mode (10,200,0,100,255,123).'

Traceback (most recent call last):
  File "metering/__main__.py", line 143, in main
    ftp.storbinary('STOR {}'.format(filename), stream)
  File ".../lib/python3.5/ftplib.py", line 503, in storbinary
    with self.transfercmd(cmd, rest) as conn:
  File ".../lib/python3.5/ftplib.py", line 398, in transfercmd
    return self.ntransfercmd(cmd, rest)[0]
  File ".../lib/python3.5/ftplib.py", line 793, in ntransfercmd
    conn, size = FTP.ntransfercmd(self, cmd, rest)
  File ".../lib/python3.5/ftplib.py", line 360, in ntransfercmd
    source_address=self.source_address)
  File ".../lib/python3.5/socket.py", line 711, in create_connection
    raise err
  File ".../lib/python3.5/socket.py", line 702, in create_connection
    sock.connect(sa)
OSError: [Errno 113] No route to host

The most confusing aspect was that the transfer worked perfectly well via FileZilla or Gnome "Connect to Server".

I eventually noticed a message in the FileZilla logs, Server sent passive reply with unroutable address. Using server address instead. It turns out that the FTPS server was mis-configured and was replying to the PASV command with an internal IP address that was not accessible from the public internet. It seems that this is a common enough configuration issue that some FTP clients detect the problem and use the existing server address instead. Python's FTP client doesn't do this though.

The solution was to sub-class the FTP_TLS class and force it to ignore the response:

class FTP_TLS_IgnoreHost(ftplib.FTP_TLS):
    def makepasv(self):
        _, port = super().makepasv()
        return self.host, port

ftp = FTP_TLS_IgnoreHost('host', 'user', 'password')

The makepasv method parses the remote server's response to PASV and returns a host and a port. We're extending this method to throw away the returned host and use the one we already have from the original connection. The underscore is just a convention to indicate that we don't care about the first item in the tuple, the host (it's special in some languages like Prolog, but not in Python). Note of course that this approach doesn't attempt to detect unroutable addresses like FileZilla, it just assumes.

There are no doubt third-party packages that provide this functionality and more, but for such a small extension, it wasn't worth the hassle.

blogroll

social