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:
- Legacy systems β industrial equipment, mainframes, and embedded devices often speak FTP natively.
- Simple automation β cron jobs, batch scripts, and monitoring tools still rely on FTP for log retrieval or firmware updates.
- Educational value β implementing FTP from scratch teaches socket programming, protocol parsing, and state management.
- Low resource overhead β FTP servers can run on minimal hardware, making them ideal for IoT gateways.
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:
USERβ specify usernamePASSβ specify passwordPWDβ print working directoryCWDβ change working directoryPORTβ specify client address for active mode data connectionPASVβ request passive mode (server listens and returns address)LISTβ get directory listing (requires data connection)RETRβ retrieve (download) a fileSTORβ store (upload) a fileQUITβ close the control connection
Reply codes are grouped by the first digit:
- 1xx β positive preliminary (e.g., 150 Opening data connection)
- 2xx β positive completion (e.g., 226 Transfer complete)
- 3xx β positive intermediate (e.g., 331 Password required)
- 4xx β transient negative (e.g., 421 Too many users)
- 5xx β permanent negative (e.g., 550 File not found)
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:
- Establish a control connection
- Authenticate with USER/PASS
- Request passive mode
- List a directory
- Download a file
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
- Always prefer passive mode β it works reliably through NATs and firewalls. Active mode should only be used in controlled environments.
- Implement proper reply code handling β do not assume a single-line reply; multi-line replies (code followed by a hyphen) are allowed. Read until a line with the same code followed by a space.
- Timeouts and retries β set socket timeouts on both control and data connections to avoid hanging indefinitely.
- Sanitize paths β in server implementations, prevent directory traversal attacks by normalizing paths and ensuring they stay within the root directory.
- Use binary mode for file transfers β FTP defaults to ASCII mode, which can corrupt binary files (end-of-line conversion). Always issue
TYPE Ibefore transferring non-text files. - Log and monitor β keep detailed logs of authentication attempts and transfer activities for security audits.
- Secure with TLS (FTPS) β plain FTP sends credentials and data in the clear. Consider wrapping the control and data connections with TLS as per RFC 4217, or migrate to SFTP/SCP for modern deployments.
- Handle multiple connections β production servers should use threading, forking, or asynchronous I/O to serve many clients concurrently.
- Test interoperability β validate your implementation against popular FTP clients (FileZilla, WinSCP, lftp) and servers (vsftpd, Pure-FTPd) to ensure compatibility.
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.