首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Java 實作 FTP 伺服器核心架構:協定解析、多執行緒與檔案傳輸實戰

     編輯:Java 相關知識

前言

FTP (File Transfer Protocol, RFC 959) 是經典的檔案傳輸協定,雖然現代多改用 SFTP/HTTPS,但理解其雙通道架構(控制連線 + 資料連線)、主動/被動模式、指令回應機制,對網路程式設計基礎訓練極有價值。本文完整解析 Java 實作 FTP Server 核心模組:協定處理、多執行緒架構、檔案操作與安全考量。

一、FTP 協定核心概念

雙通道架構

  • 控制連線 (Control Connection):埠 21,傳輸指令/回應,全雙工、持久連線
  • 資料連線 (Data Connection):傳輸檔案內容/目錄列表,每次傳輸建立、傳完關閉

主動模式 vs 被動模式

模式資料連線發起方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 建立/刪除目錄

回應碼分類

  • 1xx:正在處理 (如 150 資料連線開啟)
  • 2xx:成功 (200 指令OK, 226 傳輸完成, 230 登入成功)
  • 3xx:需進一步動作 (331 需密碼, 350 需新檔名)
  • 4xx:暫時失敗 (425 無資料連線, 450 檔案不可用)
  • 5xx:永久失敗 (500 語法錯誤, 530 未登入, 550 檔案不存在)

二、伺服器架構設計

主類別:監聽埠 21,分派客戶端執行緒


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();
    }
    
    // ... 以下各指令處理方法
}

三、關鍵指令實作詳解

1. 被動模式 (PASV) - 現代標準


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);
    }
}

2. 檔案下載 (RETR)


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();
    }
}

3. 檔案上傳 (STOR)


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();
    }
}

4. 目錄列表 (LIST)


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();
    }
}

5. 路徑解析與安全防禦 (目錄遍歷攻擊防護)


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);
    }
}

四、PORT 指令解析 (主動模式)


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 映射配置、健康檢查端點

六、安全性檢查清單

  1. ✅ 路徑遍歷防護:getCanonicalPath() + 根目錄前綴檢查
  2. ✅ 指令注入防護:嚴格解析指令格式,拒絕異常字元
  3. ✅ 資源限制:最大連線數、單連線頻寬、單會話檔案大小、閒置超時
  4. ✅ 日誌審計:記錄登入、檔案操作、異常存取、IP 來源
  5. ✅ 最小權限原則:執行程序用戶僅擁有根目錄讀寫權
  6. ⚠️ 明文傳輸風險:生產環境必須啟用 FTPS (Explicit TLS on port 21)

總結

Java 實作 FTP Server 核心在於:雙通道狀態機管理、被動模式 ServerSocket 動態分配、RandomAccessFile 檔案操作、路徑正規化安全防禦。雖然 FTP 協定因明文傳輸、NAT 穿透困難逐漸被 SFTP/HTTPS 取代,但其架構設計思想(控制/資料分離、狀態機指令處理、多執行緒會話管理)是網路程式設計的經典教材,深入實作能強化對 TCP/IP、應用層協定、併發程式設計的綜合掌握。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved