FTP (File Transfer Protocol, RFC 959) 是經典的檔案傳輸協定,雖然現代多改用 SFTP/HTTPS,但理解其雙通道架構(控制連線 + 資料連線)、主動/被動模式、指令回應機制,對網路程式設計基礎訓練極有價值。本文完整解析 Java 實作 FTP Server 核心模組:協定處理、多執行緒架構、檔案操作與安全考量。
| 模式 | 資料連線發起方 | PORT 指令內容 | 防火牆友善度 |
|---|---|---|---|
| 主動模式 | 伺服器主動連線客戶端 | 客戶端 IP + 埠 | 差 (客戶端需開放入站) |
| 被動模式 | 客戶端主動連線伺服器 | 伺服器回應 IP + 埠 (PASV 回應) | 佳 (僅伺服器開放埠範圍) |
// 存取控制
USER // 331 需密碼
PASS // 230 登入成功
QUIT // 221 再見
// 目錄操作
CWD // 250 切換目錄
CDUP // 250 回上層
PWD // 257 當前目錄
LIST [path] // 150/226 目錄列表 (資料連線)
NLST [path] // 150/226 精簡名單
// 檔案傳輸
TYPE I/A // 200 二進制/ASCII 模式
PASV // 227 進入被動模式
PORT h1,h2,h3,h4,p1,p2 // 200 指定主動模式位址
RETR // 150/226 下載檔案
STOR // 150/226 上傳檔案
DELE // 250 刪除檔案
RNFR/RNTO // 350/250 重命名
MKD/RMD // 257/250 建立/刪除目錄
public class FtpServer {
private final int controlPort;
private final String rootDirectory;
private final ExecutorService threadPool;
private volatile boolean running = false;
public FtpServer(int controlPort, String rootDirectory, int maxThreads) {
this.controlPort = controlPort;
this.rootDirectory = rootDirectory;
this.threadPool = Executors.newFixedThreadPool(maxThreads,
new ThreadFactoryBuilder().setNameFormat("ftp-client-%d").build());
}
public void start() throws IOException {
try (ServerSocket serverSocket = new ServerSocket(controlPort)) {
serverSocket.setReuseAddress(true);
running = true;
System.out.println("FTP Server 啟動於埠 " + controlPort);
while (running) {
Socket clientSocket = serverSocket.accept();
clientSocket.setSoTimeout(300000); // 5 分鐘閒置超時
threadPool.execute(new ClientSession(clientSocket, rootDirectory));
}
}
}
public void stop() {
running = false;
threadPool.shutdown();
try { threadPool.awaitTermination(10, TimeUnit.SECONDS); }
catch (InterruptedException e) { threadPool.shutdownNow(); }
}
public static void main(String[] args) throws IOException {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 21;
String root = args.length > 1 ? args[1] : System.getProperty("user.dir");
new FtpServer(port, root, 50).start();
}
}
public class ClientSession implements Runnable {
private final Socket controlSocket;
private final String rootDir;
private PrintWriter out;
private BufferedReader in;
private String currentDir;
private boolean loggedIn = false;
private String username;
private DataConnectionMode dataMode = DataConnectionMode.PASSIVE;
private ServerSocket passiveServerSocket;
private String passiveHost;
private int passivePort;
public ClientSession(Socket socket, String rootDir) {
this.controlSocket = socket;
this.rootDir = rootDir;
this.currentDir = rootDir;
}
@Override
public void run() {
try {
out = new PrintWriter(controlSocket.getOutputStream(), true);
in = new BufferedReader(new InputStreamReader(controlSocket.getInputStream(), StandardCharsets.ISO_8859_1));
sendResponse(220, "FTP Server ready (Java Implementation)");
String line;
while ((line = in.readLine()) != null && !controlSocket.isClosed()) {
processCommand(line.trim());
}
} catch (IOException e) {
// 連線斷開
} finally {
closeDataConnection();
closePassiveSocket();
IOUtils.closeQuietly(controlSocket);
}
}
private void processCommand(String line) {
String[] parts = line.split("\\s+", 2);
String cmd = parts[0].toUpperCase();
String arg = parts.length > 1 ? parts[1] : "";
switch (cmd) {
case "USER" -> handleUser(arg);
case "PASS" -> handlePass(arg);
case "QUIT" -> handleQuit();
case "CWD" -> handleCwd(arg);
case "CDUP" -> handleCdup();
case "PWD" -> handlePwd();
case "LIST" -> handleList(arg);
case "TYPE" -> handleType(arg);
case "PASV" -> handlePasv();
case "PORT" -> handlePort(arg);
case "RETR" -> handleRetr(arg);
case "STOR" -> handleStor(arg);
case "DELE" -> handleDele(arg);
case "RNFR" -> handleRnfr(arg);
case "RNTO" -> handleRnto(arg);
case "MKD" -> handleMkD(arg);
case "RMD" -> handleRmD(arg);
default -> sendResponse(500, "Syntax error, command unrecognized: " + cmd);
}
}
private void sendResponse(int code, String message) {
out.println(code + " " + message);
out.flush();
}
// ... 以下各指令處理方法
}
private void handlePasv() {
try {
// 在指定範圍內建立被動模式 ServerSocket
passiveServerSocket = new ServerSocket(0); // 0 = 自動分配可用埠
passiveServerSocket.setSoTimeout(30000);
passivePort = passiveServerSocket.getLocalPort();
InetAddress localAddr = controlSocket.getLocalAddress();
passiveHost = localAddr.getHostAddress();
// 回應格式:227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
// p1 = port / 256, p2 = port % 256
int p1 = passivePort / 256;
int p2 = passivePort % 256;
String ipParts = passiveHost.replace('.', ',');
sendResponse(227, "Entering Passive Mode (" + ipParts + "," + p1 + "," + p2 + ")");
dataMode = DataConnectionMode.PASSIVE;
} catch (IOException e) {
sendResponse(425, "Can't open data connection");
}
}
// 取得資料連線 Socket (被動模式)
private Socket getDataSocket() throws IOException {
if (dataMode == DataConnectionMode.PASSIVE) {
return passiveServerSocket.accept(); // 阻塞等待客戶端連線
} else { // ACTIVE
// 解析 PORT 指令儲存的客戶端位址
return new Socket(activeHost, activePort);
}
}
private void handleRetr(String filename) {
File file = resolveFile(filename);
if (!file.exists() || !file.isFile()) {
sendResponse(550, "File not found");
return;
}
try (Socket dataSocket = getDataSocket();
FileInputStream fis = new FileInputStream(file);
OutputStream dos = dataSocket.getOutputStream()) {
sendResponse(150, "Opening BINARY mode data connection for " + file.getName());
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) != -1) {
dos.write(buffer, 0, len);
}
dos.flush();
sendResponse(226, "Transfer complete");
} catch (IOException e) {
sendResponse(426, "Connection closed; transfer aborted");
} finally {
closeDataConnection();
}
}
private void handleStor(String filename) {
File file = resolveFile(filename);
File parent = file.getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs();
try (Socket dataSocket = getDataSocket();
FileOutputStream fos = new FileOutputStream(file);
InputStream dis = dataSocket.getInputStream()) {
sendResponse(150, "Opening BINARY mode data connection for " + file.getName());
byte[] buffer = new byte[8192];
int len;
while ((len = dis.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
sendResponse(226, "Transfer complete");
} catch (IOException e) {
sendResponse(426, "Connection closed; transfer aborted");
} finally {
closeDataConnection();
}
}
private void handleList(String pathArg) {
File dir = pathArg.isEmpty() ? new File(currentDir) : resolveFile(pathArg);
if (!dir.exists() || !dir.isDirectory()) {
sendResponse(550, "Directory not found");
return;
}
try (Socket dataSocket = getDataSocket();
PrintWriter writer = new PrintWriter(new OutputStreamWriter(dataSocket.getOutputStream(), StandardCharsets.ISO_8859_1))) {
sendResponse(150, "Here comes the directory listing");
File[] files = dir.listFiles();
if (files != null) {
for (File f : files) {
// Unix ls -l 格式
String perms = (f.isDirectory() ? "d" : "-") + "rw-r--r--";
String owner = System.getProperty("user.name");
long size = f.length();
String time = new SimpleDateFormat("MMM dd HH:mm", Locale.US).format(f.lastModified());
writer.println(String.format("%s 1 %s %s %8d %s %s",
perms, owner, owner, size, time, f.getName()));
}
}
sendResponse(226, "Directory send OK");
} catch (IOException e) {
sendResponse(426, "Connection closed");
} finally {
closeDataConnection();
}
}
private File resolveFile(String path) {
File base = new File(rootDir).getAbsoluteFile();
File target;
if (path.startsWith("/")) {
target = new File(base, path.substring(1));
} else {
target = new File(new File(currentDir), path);
}
// 關鍵:正規化路徑並檢查是否逃出根目錄
try {
File canonicalBase = base.getCanonicalFile();
File canonicalTarget = target.getCanonicalFile();
if (!canonicalTarget.getAbsolutePath().startsWith(canonicalBase.getAbsolutePath())) {
throw new SecurityException("Attempted path traversal: " + path);
}
return canonicalTarget;
} catch (IOException e) {
throw new SecurityException("Invalid path: " + path);
}
}
private void handlePort(String arg) {
// 格式:h1,h2,h3,h4,p1,p2
String[] parts = arg.split(",");
if (parts.length != 6) {
sendResponse(501, "Invalid PORT format");
return;
}
try {
StringBuilder ip = new StringBuilder();
for (int i = 0; i < 4; i++) {
if (i > 0) ip.append('.');
ip.append(parts[i]);
}
activeHost = ip.toString();
int p1 = Integer.parseInt(parts[4]);
int p2 = Integer.parseInt(parts[5]);
activePort = p1 * 256 + p2;
dataMode = DataConnectionMode.ACTIVE;
sendResponse(200, "PORT command successful");
} catch (NumberFormatException e) {
sendResponse(501, "Invalid PORT numbers");
}
}
| 領域 | 改良方向 |
|---|---|
| 協定支援 | FTPS (SSL/TLS)、IPv6 (EPSV/EPRT)、UTF-8 檔名 (RFC 2640) |
| 併發模型 | NIO (Netty/Mina) 非阻塞、虛擬執行緒 (Java 21+) |
| 認證授權 | LDAP/DB 整合、虛擬用戶、權限 ACL (每目錄讀寫控制) |
| 傳輸優化 | 零拷貝、壓縮傳輸 (MODE Z)、大檔案斷點續傳 (REST) |
| 監控管理 | JMX 指標、即時會話查看、頻寬限流、IP 白名單 |
| 容器化 | 被動模式埠範圍固定、外部 IP 映射配置、健康檢查端點 |
getCanonicalPath() + 根目錄前綴檢查Java 實作 FTP Server 核心在於:雙通道狀態機管理、被動模式 ServerSocket 動態分配、RandomAccessFile 檔案操作、路徑正規化安全防禦。雖然 FTP 協定因明文傳輸、NAT 穿透困難逐漸被 SFTP/HTTPS 取代,但其架構設計思想(控制/資料分離、狀態機指令處理、多執行緒會話管理)是網路程式設計的經典教材,深入實作能強化對 TCP/IP、應用層協定、併發程式設計的綜合掌握。