← Back to DevBytes

Implementing FTP Protocol: From Theory to Practice

Understanding the FTP Protocol

The File Transfer Protocol (FTP) is one of the oldest and most fundamental network protocols still in active use. Standardized in RFC 959, FTP enables reliable file transfers between a client and a server over TCP/IP networks. Its design separates two distinct communication channels: a control connection for sending commands and receiving replies, and a separate data connection for the actual transfer of file contents or directory listings.

FTP uses a client-server architecture. The client initiates a control connection to port 21 on the server, then authenticates using credentials. After authentication, the client issues commands like RETR (retrieve), STOR (store), or LIST to trigger data transfers. The data connection can be established in two modes: active (the server connects back to the client) or passive (the client connects to the server). This separation allows the protocol to work through firewalls and NAT devices, making passive mode the dominant choice today.

Why FTP Still Matters

Despite the rise of HTTP-based file sharing, SFTP (SSH File Transfer Protocol), and cloud storage APIs, FTP remains relevant in many domains:

Understanding FTP internals gives you a solid foundation for building custom client-server applications and debugging network issues.

How FTP Works: A Deep Dive

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The control connection is a text-based dialogue. The client sends commands terminated with CRLF (\r\n), and the server responds with three-digit reply codes followed by human-readable text. For example:


Client:  USER alice\r\n
Server:  331 Password required for alice\r\n
Client:  PASS secret\r\n
Server:  230 User alice logged in\r\n

Important FTP commands include:

Reply codes are grouped by the first digit:

Active vs Passive Mode

Active mode (PORT): After the client sends a PORT command containing its IP address and a random port number (encoded as six decimal numbers, e.g., PORT 192,168,1,10,14,37), the server initiates a TCP connection from port 20 to the specified client port. This often fails if the client is behind a NAT or firewall.

Passive mode (PASV): The client sends PASV, and the server responds with a message like 227 Entering Passive Mode (192,168,1,20,15,0), indicating the server’s IP and port to which the client should connect for data. The client then opens a TCP connection to that address. This works seamlessly across firewalls and is the recommended approach.

Implementing an FTP Client in Python

We'll build a minimal FTP client using Python's socket module. The client will:

All code is written for Python 3 and uses only the standard library.

Step 1: Connect and Authenticate


import socket

def connect_and_auth(host, port, user, password):
    # Create control socket
    ctrl = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    ctrl.connect((host, port))
    
    # Read initial greeting (expect 220)
    banner = ctrl.recv(1024).decode()
    print("S: " + banner.strip())
    
    # Send USER
    cmd = f"USER {user}\r\n"
    ctrl.sendall(cmd.encode())
    resp = ctrl.recv(1024).decode()
    print("S: " + resp.strip())
    if not resp.startswith("331"):
        raise Exception("USER command failed")
    
    # Send PASS
    cmd = f"PASS {password}\r\n"
    ctrl.sendall(cmd.encode())
    resp = ctrl.recv(1024).decode()
    print("S: " + resp.strip())
    if not resp.startswith("230"):
        raise Exception("Authentication failed")
    
    return ctrl

Step 2: Request Passive Mode and Parse the Address

The PASV response format is 227 (h1,h2,h3,h4,p1,p2). We extract the IP and port.


import re

def enter_passive(ctrl):
    ctrl.sendall(b"PASV\r\n")
    resp = ctrl.recv(1024).decode()
    print("S: " + resp.strip())
    if not resp.startswith("227"):
        raise Exception("PASV command failed")
    
    # Extract numbers from response
    match = re.search(r"(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)", resp)
    if not match:
        raise Exception("Invalid PASV response")
    nums = [int(x) for x in match.groups()]
    ip = f"{nums[0]}.{nums[1]}.{nums[2]}.{nums[3]}"
    port = (nums[4] << 8) + nums[5]
    return ip, port

Step 3: Listing Directory Contents

To list files, send LIST, open the data connection, read all data, then close it.


def list_directory(ctrl):
    ip, port = enter_passive(ctrl)
    # Open data socket
    data_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    data_sock.connect((ip, port))
    
    # Send LIST command
    ctrl.sendall(b"LIST\r\n")
    resp = ctrl.recv(1024).decode()
    print("S: " + resp.strip())
    if not resp.startswith("150"):
        # Some servers reply 125 or 150
        pass
    
    # Read data
    data = b""
    while True:
        chunk = data_sock.recv(4096)
        if not chunk:
            break
        data += chunk
    data_sock.close()
    
    # Wait for transfer complete reply (226)
    final = ctrl.recv(1024).decode()
    print("S: " + final.strip())
    
    return data.decode()

Step 4: Downloading a File

RETR works similarly: after entering passive mode, send RETR filename and read the data socket until it closes.


def download_file(ctrl, remote_path, local_path):
    ip, port = enter_passive(ctrl)
    data_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    data_sock.connect((ip, port))
    
    # Send RETR
    cmd = f"RETR {remote_path}\r\n"
    ctrl.sendall(cmd.encode())
    resp = ctrl.recv(1024).decode()
    print("S: " + resp.strip())
    if resp.startswith("550"):
        raise Exception("File not found")
    
    # Receive and write to local file
    with open(local_path, "wb") as f:
        while True:
            chunk = data_sock.recv(4096)
            if not chunk:
                break
            f.write(chunk)
    data_sock.close()
    
    final = ctrl.recv(1024).decode()
    print("S: " + final.strip())
    if not final.startswith("226"):
        raise Exception("Transfer failed or incomplete")
    print(f"Downloaded '{remote_path}' β†’ '{local_path}'")

Full Client Example


if __name__ == "__main__":
    HOST = "ftp.example.com"
    PORT = 21
    USER = "alice"
    PASS = "secret"
    
    try:
        ctrl = connect_and_auth(HOST, PORT, USER, PASS)
        listing = list_directory(ctrl)
        print("Directory listing:\n" + listing)
        download_file(ctrl, "README.txt", "local_readme.txt")
    finally:
        ctrl.sendall(b"QUIT\r\n")
        resp = ctrl.recv(1024).decode()
        print("S: " + resp.strip())
        ctrl.close()

Implementing a Basic FTP Server

Writing a server deepens understanding. We'll implement a single-threaded server handling one connection at a time (real-world servers would fork or use async I/O). The server will support USER/PASS authentication, PASV, LIST, RETR, STOR, and QUIT.

Server Skeleton


import socket
import os
import threading

FTP_PORT = 21
DATA_BASE_PORT = 60000   # range for passive data ports

class FTPServer:
    def __init__(self, host="0.0.0.0", port=FTP_PORT, root_dir="/srv/ftp"):
        self.host = host
        self.port = port
        self.root = root_dir
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind((host, port))
        self.sock.listen(1)
        print(f"FTP server listening on {host}:{port}")
    
    def start(self):
        while True:
            conn, addr = self.sock.accept()
            print(f"New connection from {addr}")
            handler = FTPHandler(conn, addr, self.root)
            handler.run()
            conn.close()

Handling Commands

The handler reads lines, processes commands, and manages a separate data socket for passive mode.


class FTPHandler:
    def __init__(self, conn, addr, root_dir):
        self.conn = conn
        self.addr = addr
        self.root = os.path.abspath(root_dir)
        self.cwd = "/"
        self.authenticated = False
        self.username = None
        self.data_sock = None
        self.pasv_port = None
        
    def send(self, code, msg):
        self.conn.sendall(f"{code} {msg}\r\n".encode())
    
    def run(self):
        self.send(220, "Welcome to MiniFTP")
        while True:
            try:
                data = self.conn.recv(1024)
                if not data:
                    break
                line = data.decode().strip()
                if not line:
                    continue
                cmd, *args = line.split()
                cmd = cmd.upper()
                if cmd == "QUIT":
                    self.send(221, "Goodbye")
                    break
                elif cmd == "USER":
                    self.handle_user(args)
                elif cmd == "PASS":
                    self.handle_pass(args)
                elif cmd == "PWD":
                    self.handle_pwd()
                elif cmd == "CWD":
                    self.handle_cwd(args)
                elif cmd == "PASV":
                    self.handle_pasv()
                elif cmd == "LIST":
                    self.handle_list()
                elif cmd == "RETR":
                    self.handle_retr(args)
                elif cmd == "STOR":
                    self.handle_stor(args)
                else:
                    self.send(502, "Command not implemented")
            except Exception as e:
                self.send(550, str(e))
        self.cleanup()

Passive Mode Implementation

When a client sends PASV, the server creates a temporary listening socket, reports its IP and port in the (h1,h2,h3,h4,p1,p2) format, then waits for the client to connect.


def handle_pasv(self):
    # Create a listening socket on a free port
    data_listen = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    data_listen.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    data_listen.bind(('0.0.0.0', 0))  # port 0 = OS chooses
    data_listen.listen(1)
    self.pasv_port = data_listen.getsockname()[1]
    self.data_listen = data_listen
    
    # Build response: IP and port
    ip = self.conn.getsockname()[0]  # server IP seen by client
    parts = ip.split('.')
    p1 = self.pasv_port >> 8
    p2 = self.pasv_port & 0xFF
    self.send(227, f"Entering Passive Mode ({parts[0]},{parts[1]},{parts[2]},{parts[3]},{p1},{p2})")

Before handling a data command (LIST, RETR, STOR), the server must accept the client’s data connection. We do this inside a helper:


def accept_data_connection(self):
    if not self.data_listen:
        self.send(425, "Use PASV first")
        return None
    try:
        self.data_listen.settimeout(30)
        data_conn, addr = self.data_listen.accept()
        self.data_listen.close()
        self.data_listen = None
        return data_conn
    except socket.timeout:
        self.send(421, "Data connection timeout")
        return None

Handling LIST and RETR


def handle_list(self):
    data_conn = self.accept_data_connection()
    if not data_conn:
        return
    self.send(150, "Opening ASCII mode data connection for file list")
    # Build directory listing
    path = os.path.join(self.root, self.cwd.lstrip('/'))
    listing = ""
    for entry in os.listdir(path):
        listing += entry + "\r\n"
    data_conn.sendall(listing.encode())
    data_conn.close()
    self.send(226, "Transfer complete")

def handle_retr(self, args):
    if len(args) < 1:
        self.send(501, "Syntax error")
        return
    filename = args[0]
    path = os.path.join(self.root, self.cwd.lstrip('/'), filename)
    if not os.path.isfile(path):
        self.send(550, "File not found")
        return
    data_conn = self.accept_data_connection()
    if not data_conn:
        return
    self.send(150, f"Opening BINARY mode data connection for {filename}")
    with open(path, "rb") as f:
        while True:
            chunk = f.read(4096)
            if not chunk:
                break
            data_conn.sendall(chunk)
    data_conn.close()
    self.send(226, "Transfer complete")

STOR (upload) follows the same pattern but writes data from the socket to a local file.

Best Practices for FTP Implementation

Conclusion

Implementing FTP from scratch is a rewarding exercise that illuminates the inner workings of a classic Internet protocol. By building both a client and a server, you gain hands-on experience with socket programming, text-based command parsing, and the subtleties of dual-channel communication. While plain FTP is no longer suitable for production environments due to its lack of encryption, the core concepts remain valuable for understanding protocol design, debugging legacy systems, and developing lightweight transfer mechanisms. Use the examples in this tutorial as a starting point, then extend them with features like TLS encryption, resume support (REST), and concurrent handling to deepen your expertise.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles