FTP (File Transfer Protocol) 作為經典文件傳輸協議,雖然安全性不如 SFTP,但在內網文件分發、舊系統集成、嵌入式設備等場景仍不可替代。本文從協議核心命令入手,實現一個功能完整的 Java FTP 服務器雛形。
| 類別 | 命令 | 功能 | 響應碼示例 |
|---|---|---|---|
| 訪問控制 | USER <username> | 提交用戶名 | 331 Password required |
| PASS <password> | 提交密碼 | 230 Login successful | |
| QUIT | 斷開連接 | 221 Goodbye | |
| 目錄操作 | CWD <path> | 切換工作目錄 | 250 Directory changed |
| CDUP | 返回父目錄 | 250 Directory changed | |
| PWD | 顯示當前目錄 | 257 "/home/user" | |
| 傳輸參數 | TYPE [A|I] | 設置傳輸模式 (ASCII/Binary) | 200 Type set to I |
| PORT h1,h2,h3,h4,p1,p2 | 主動模式數據端口 | 200 PORT command successful | |
| PASV | 被動模式 (服務器開放端口) | 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2) | |
| 文件傳輸 | RETR <file> | 下載文件 | 150 Opening data connection / 226 Transfer complete |
| STOR <file> | 上傳文件 | 150 Opening data connection / 226 Transfer complete | |
| DELE <file> | 刪除文件 | 250 File deleted | |
| LIST [path] | 列表目錄/文件 | 150 Opening data connection / 226 Transfer complete |
ServerSocket (21端口) 接受連接
│
├─ SessionThread (每客戶端一線程)
│ ├─ 解析控制命令 (USER, PASS, CWD, TYPE, PORT, PASV, QUIT...)
│ ├─ 維護會話狀態 (用戶、工作目錄、傳輸模式、數據連接模式)
│ └─ 需數據傳輸時 (RETR, STOR, LIST) → 建立/接受數據 Socket
│
└─ DataConnection (臨時)
├─ 主動模式: 客戶端發 PORT → 服務器連接客戶端 IP:Port
└─ 被動模式: 服務器開放隨機端口 → 客戶端連接服務器
public class SimpleFtpServer {
private static final int CONTROL_PORT = 21;
private static String rootDirectory = "C:/ftp_root"; // 可通過參數配置
public static void main(String[] args) {
if (args.length > 0) rootDirectory = args[0];
try (ServerSocket serverSocket = new ServerSocket(CONTROL_PORT)) {
System.out.println("FTP Server started on port " + CONTROL_PORT + ", root: " + rootDirectory);
while (true) {
Socket clientSocket = serverSocket.accept();
new SessionThread(clientSocket).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
class SessionThread extends Thread {
private Socket controlSocket;
private BufferedReader in;
private PrintWriter out;
private String currentDir = "/";
private boolean authenticated = false;
private String username;
private int dataPort; // PORT 模式客戶端端口
private String dataHost; // PORT 模式客戶端 IP
private ServerSocket passiveServerSocket; // PASV 模式服務端 Socket
public SessionThread(Socket socket) { this.controlSocket = socket; }
@Override
public void run() {
try {
in = new BufferedReader(new InputStreamReader(controlSocket.getInputStream()));
out = new PrintWriter(controlSocket.getOutputStream(), true);
sendResponse(220, "Welcome to Simple Java FTP Server");
String line;
while ((line = in.readLine()) != null) {
System.out.println("CMD: " + line);
processCommand(line.trim());
}
} catch (IOException e) {
e.printStackTrace();
} finally {
closeQuietly();
}
}
private void processCommand(String cmd) {
if (cmd.startsWith("USER ")) {
username = cmd.substring(5).trim();
sendResponse(331, "Password required for " + username);
} else if (cmd.startsWith("PASS ")) {
// 簡單演示:接受任意密碼,生產環境需對接認證系統
authenticated = true;
currentDir = rootDirectory;
sendResponse(230, "User " + username + " logged in");
} else if (!authenticated) {
sendResponse(530, "Please login with USER and PASS");
} else if (cmd.startsWith("CWD ")) {
String path = cmd.substring(4).trim();
File newDir = resolvePath(path);
if (newDir.exists() && newDir.isDirectory()) {
currentDir = newDir.getAbsolutePath();
sendResponse(250, "Directory changed to " + currentDir);
} else sendResponse(550, "Directory not found");
} else if (cmd.equals("CDUP")) {
File parent = new File(currentDir).getParentFile();
if (parent != null) {
currentDir = parent.getAbsolutePath();
sendResponse(250, "Directory changed to " + currentDir);
} else sendResponse(550, "Already at root");
} else if (cmd.startsWith("TYPE ")) {
// 簡單支持: A=ASCII, I=Binary (圖片/壓縮包必須用 Binary)
sendResponse(200, "Type set to " + cmd.substring(5).trim());
} else if (cmd.startsWith("PORT ")) {
// 解析 PORT h1,h2,h3,h4,p1,p2
String[] parts = cmd.substring(5).split(",");
if (parts.length == 6) {
dataHost = String.join(".", parts[0], parts[1], parts[2], parts[3]);
dataPort = (Integer.parseInt(parts[4]) << 8) + Integer.parseInt(parts[5]);
sendResponse(200, "PORT command successful");
} else sendResponse(501, "Invalid PORT format");
} else if (cmd.equals("PASV")) {
// 被動模式:在隨機端口監聽
try {
passiveServerSocket = new ServerSocket(0); // 系統分配端口
int port = passiveServerSocket.getLocalPort();
int p1 = port >> 8, p2 = port & 0xFF;
String localIp = InetAddress.getLocalHost().getHostAddress().replace('.', ',');
sendResponse(227, "Entering Passive Mode (" + localIp + "," + p1 + "," + p2 + ")");
} catch (IOException e) {
sendResponse(425, "Can't open passive connection");
}
} else if (cmd.startsWith("RETR ")) {
handleDataTransfer(cmd.substring(5).trim(), true); // 下載
} else if (cmd.startsWith("STOR ")) {
handleDataTransfer(cmd.substring(5).trim(), false); // 上傳
} else if (cmd.equals("LIST")) {
handleList();
} else if (cmd.startsWith("DELE ")) {
File file = resolvePath(cmd.substring(5).trim());
if (file.delete()) sendResponse(250, "File deleted");
else sendResponse(550, "Delete failed");
} else if (cmd.equals("QUIT")) {
sendResponse(221, "Goodbye");
closeQuietly();
} else {
sendResponse(502, "Command not implemented");
}
}
private void handleDataTransfer(String fileName, boolean isDownload) {
Socket dataSocket = null;
try {
// 1. 建立數據連接
if (dataHost != null && dataPort > 0) {
// 主動模式
dataSocket = new Socket(dataHost, dataPort);
dataHost = null; dataPort = 0; // 用完重置
} else if (passiveServerSocket != null) {
// 被動模式
dataSocket = passiveServerSocket.accept();
passiveServerSocket.close();
passiveServerSocket = null;
} else {
sendResponse(425, "No data connection established (use PORT or PASV)");
return;
}
sendResponse(150, "Opening " + (isDownload ? "download" : "upload") + " data connection");
File file = resolvePath(fileName);
byte[] buffer = new byte[8192];
int len;
if (isDownload) {
// RETR: 文件 → 數據 Socket 輸出流
try (FileInputStream fis = new FileInputStream(file);
OutputStream dos = dataSocket.getOutputStream()) {
while ((len = fis.read(buffer)) != -1) dos.write(buffer, 0, len);
}
} else {
// STOR: 數據 Socket 輸入流 → 文件
try (FileOutputStream fos = new FileOutputStream(file);
InputStream dis = dataSocket.getInputStream()) {
while ((len = dis.read(buffer)) != -1) fos.write(buffer, 0, len);
}
}
sendResponse(226, "Transfer complete");
} catch (IOException e) {
sendResponse(426, "Transfer aborted: " + e.getMessage());
} finally {
closeQuietly(dataSocket);
}
}
private void handleList() {
// 類似 handleDataTransfer,但發送目錄列表文本
// 格式: "drwxr-xr-x 1 user group 0 Jan 1 00:00 dirname"
// 或簡化: "d dirname" / "- filename"
}
private File resolvePath(String relativePath) {
// 安全處理:防止目錄遍歷攻擊 (../../etc/passwd)
File base = new File(rootDirectory).getAbsoluteFile();
File target = new File(base, relativePath).getAbsoluteFile();
if (!target.getPath().startsWith(base.getPath())) {
return base; // 越權訪問限制在根目錄
}
return target;
}
private void sendResponse(int code, String message) {
out.println(code + " " + message);
out.flush();
}
private void closeQuietly() {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (Exception ignored) {}
try { if (controlSocket != null) controlSocket.close(); } catch (IOException ignored) {}
try { if (passiveServerSocket != null) passiveServerSocket.close(); } catch (IOException ignored) {}
}
}
| 問題 | 解決方案 |
|---|---|
| 明文傳輸密碼/數據 | 生產環境必須遷移至 FTPS (SSL/TLS) 或 SFTP (SSH) |
| 目錄遍歷攻擊 | resolvePath 規範化路徑並校驗前綴 |
| 主動模式防火牆阻擋 | 優先實現/推薦被動模式 (PASV),並配置被動端口範圍 |
| 大文件傳輸中斷 | 支持 REST (斷點續傳) 命令 |
| 併發連接耗盡 | 線程池 + 連接數限制 + 空閒超時踢出 |
| 匿名/弱口令 | 強制強密碼策略、賬號鎖定、IP 白名單 |
理解 FTP 協議細節與多線程數據連接模型,是排查傳輸故障、實現定制化文件服務的基礎。