From 5b276a4d8a9510151357915262f052f582f72c56 Mon Sep 17 00:00:00 2001 From: Gyubin Han Date: Thu, 1 Jan 2026 02:53:48 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20Passive=20Mode=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FTPDataConnection 클래스 생성: 데이터 연결 관리 * Passive Mode를 위한 ServerSocket 생성 * 동적 포트 할당 및 클라이언트 연결 수락 * 데이터 송수신 기능 (바이트 배열, 문자열, 스트림) * 30초 타임아웃 설정 - FTPSession에 데이터 연결 통합 * dataConnection 필드 및 transferType 필드 추가 * 세션 종료 시 데이터 연결 자동 닫기 - PASV 명령어 구현 * Passive Mode 진입 * 서버 주소와 포트를 h1,h2,h3,h4,p1,p2 형식으로 응답 * 이전 데이터 연결 자동 종료 - TYPE 명령어 구현 * ASCII (A) 및 Binary (I) 모드 지원 * 전송 타입 상태 관리 - LIST/NLST 명령어를 데이터 연결로 수정 * PASV 명령어 선행 필수 * 데이터 연결을 통해 파일 목록 전송 * 전송 완료 후 연결 자동 종료 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../android/server/ftp/FTPDataConnection.java | 184 ++++++++++++++++++ .../be/gyu/android/server/ftp/FTPSession.java | 116 +++++++++-- 2 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/be/gyu/android/server/ftp/FTPDataConnection.java diff --git a/app/src/main/java/be/gyu/android/server/ftp/FTPDataConnection.java b/app/src/main/java/be/gyu/android/server/ftp/FTPDataConnection.java new file mode 100644 index 0000000..92d7cf6 --- /dev/null +++ b/app/src/main/java/be/gyu/android/server/ftp/FTPDataConnection.java @@ -0,0 +1,184 @@ +package be.gyu.android.server.ftp; + +import android.util.Log; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; + +public class FTPDataConnection { + private static final String TAG = "FTPDataConnection"; + private static final int DATA_CONNECTION_TIMEOUT = 30000; // 30 seconds + + private ServerSocket passiveSocket; + private Socket dataSocket; + private int passivePort; + + public boolean openPassiveMode(InetAddress bindAddress) { + try { + // Use port 0 to get a random available port + passiveSocket = new ServerSocket(0, 1, bindAddress); + passiveSocket.setSoTimeout(DATA_CONNECTION_TIMEOUT); + passivePort = passiveSocket.getLocalPort(); + + Log.i(TAG, "Passive mode enabled on port: " + passivePort); + return true; + } catch (IOException e) { + Log.e(TAG, "Error opening passive mode: " + e.getMessage()); + return false; + } + } + + public boolean acceptConnection() { + if (passiveSocket == null) { + Log.e(TAG, "Passive socket not initialized"); + return false; + } + + try { + Log.d(TAG, "Waiting for data connection..."); + dataSocket = passiveSocket.accept(); + Log.i(TAG, "Data connection established from: " + dataSocket.getInetAddress()); + return true; + } catch (IOException e) { + Log.e(TAG, "Error accepting data connection: " + e.getMessage()); + return false; + } + } + + public boolean sendData(byte[] data) { + if (dataSocket == null || dataSocket.isClosed()) { + Log.e(TAG, "Data socket not available"); + return false; + } + + try { + OutputStream out = new BufferedOutputStream(dataSocket.getOutputStream()); + out.write(data); + out.flush(); + Log.d(TAG, "Sent " + data.length + " bytes"); + return true; + } catch (IOException e) { + Log.e(TAG, "Error sending data: " + e.getMessage()); + return false; + } + } + + public boolean sendData(String text) { + return sendData(text.getBytes()); + } + + public byte[] receiveData(int maxSize) { + if (dataSocket == null || dataSocket.isClosed()) { + Log.e(TAG, "Data socket not available"); + return null; + } + + try { + InputStream in = new BufferedInputStream(dataSocket.getInputStream()); + byte[] buffer = new byte[maxSize]; + int bytesRead = in.read(buffer); + + if (bytesRead > 0) { + byte[] data = new byte[bytesRead]; + System.arraycopy(buffer, 0, data, 0, bytesRead); + Log.d(TAG, "Received " + bytesRead + " bytes"); + return data; + } else { + Log.w(TAG, "No data received"); + return null; + } + } catch (IOException e) { + Log.e(TAG, "Error receiving data: " + e.getMessage()); + return null; + } + } + + public boolean transferStream(InputStream inputStream) { + if (dataSocket == null || dataSocket.isClosed()) { + Log.e(TAG, "Data socket not available"); + return false; + } + + try { + OutputStream out = new BufferedOutputStream(dataSocket.getOutputStream()); + byte[] buffer = new byte[8192]; + int bytesRead; + long totalBytes = 0; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + } + + out.flush(); + Log.i(TAG, "Transferred " + totalBytes + " bytes via stream"); + return true; + } catch (IOException e) { + Log.e(TAG, "Error transferring stream: " + e.getMessage()); + return false; + } + } + + public boolean receiveStream(OutputStream outputStream) { + if (dataSocket == null || dataSocket.isClosed()) { + Log.e(TAG, "Data socket not available"); + return false; + } + + try { + InputStream in = new BufferedInputStream(dataSocket.getInputStream()); + byte[] buffer = new byte[8192]; + int bytesRead; + long totalBytes = 0; + + while ((bytesRead = in.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + } + + outputStream.flush(); + Log.i(TAG, "Received " + totalBytes + " bytes via stream"); + return true; + } catch (IOException e) { + Log.e(TAG, "Error receiving stream: " + e.getMessage()); + return false; + } + } + + public void close() { + try { + if (dataSocket != null && !dataSocket.isClosed()) { + dataSocket.close(); + Log.d(TAG, "Data socket closed"); + } + } catch (IOException e) { + Log.e(TAG, "Error closing data socket: " + e.getMessage()); + } + + try { + if (passiveSocket != null && !passiveSocket.isClosed()) { + passiveSocket.close(); + Log.d(TAG, "Passive socket closed"); + } + } catch (IOException e) { + Log.e(TAG, "Error closing passive socket: " + e.getMessage()); + } + + dataSocket = null; + passiveSocket = null; + } + + public int getPassivePort() { + return passivePort; + } + + public boolean isDataConnectionOpen() { + return dataSocket != null && !dataSocket.isClosed(); + } +} \ No newline at end of file diff --git a/app/src/main/java/be/gyu/android/server/ftp/FTPSession.java b/app/src/main/java/be/gyu/android/server/ftp/FTPSession.java index 6acfaa8..5729f64 100644 --- a/app/src/main/java/be/gyu/android/server/ftp/FTPSession.java +++ b/app/src/main/java/be/gyu/android/server/ftp/FTPSession.java @@ -20,10 +20,13 @@ public class FTPSession implements Runnable { private boolean isAuthenticated = false; private boolean isRunning = true; private FTPFileSystem fileSystem; + private FTPDataConnection dataConnection; + private String transferType = "A"; // A = ASCII, I = Binary public FTPSession(Socket socket) { this.controlSocket = socket; this.fileSystem = new FTPFileSystem(); + this.dataConnection = null; } @Override @@ -98,6 +101,12 @@ public class FTPSession implements Runnable { case "SIZE": handleSize(argument); break; + case "TYPE": + handleType(argument); + break; + case "PASV": + handlePasv(); + break; case "NOOP": handleNoop(); break; @@ -179,14 +188,29 @@ public class FTPSession implements Runnable { return; } - // For now, send file list directly without data connection - // Phase 4 will implement proper data connection + if (dataConnection == null) { + sendResponse(FTPResponse.CANNOT_OPEN_DATA_CONNECTION, "Use PASV first"); + return; + } + String fileList = fileSystem.formatFileList(true); - sendResponse(FTPResponse.FILE_STATUS_OK, "File list follows (inline, data connection not yet implemented)"); - writer.write(fileList); - writer.flush(); - sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK"); + sendResponse(FTPResponse.FILE_STATUS_OK, "Opening data connection for directory list"); + + if (dataConnection.acceptConnection()) { + if (dataConnection.sendData(fileList)) { + dataConnection.close(); + sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK"); + } else { + dataConnection.close(); + sendResponse(FTPResponse.CONNECTION_CLOSED, "Transfer failed"); + } + } else { + dataConnection.close(); + sendResponse(FTPResponse.CANNOT_OPEN_DATA_CONNECTION, "Cannot open data connection"); + } + + dataConnection = null; } private void handleNlst(String path) throws IOException { @@ -195,13 +219,29 @@ public class FTPSession implements Runnable { return; } - // For now, send file list directly without data connection + if (dataConnection == null) { + sendResponse(FTPResponse.CANNOT_OPEN_DATA_CONNECTION, "Use PASV first"); + return; + } + String fileList = fileSystem.formatFileList(false); - sendResponse(FTPResponse.FILE_STATUS_OK, "File list follows (inline, data connection not yet implemented)"); - writer.write(fileList); - writer.flush(); - sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Directory send OK"); + sendResponse(FTPResponse.FILE_STATUS_OK, "Opening data connection for name list"); + + if (dataConnection.acceptConnection()) { + if (dataConnection.sendData(fileList)) { + dataConnection.close(); + sendResponse(FTPResponse.CLOSING_DATA_CONNECTION, "Transfer complete"); + } else { + dataConnection.close(); + sendResponse(FTPResponse.CONNECTION_CLOSED, "Transfer failed"); + } + } else { + dataConnection.close(); + sendResponse(FTPResponse.CANNOT_OPEN_DATA_CONNECTION, "Cannot open data connection"); + } + + dataConnection = null; } private void handleMkd(String dirName) throws IOException { @@ -278,6 +318,57 @@ public class FTPSession implements Runnable { } } + private void handleType(String typeCode) throws IOException { + if (typeCode.isEmpty()) { + sendResponse(FTPResponse.SYNTAX_ERROR_PARAMETERS, "No type specified"); + return; + } + + String type = typeCode.toUpperCase(); + if (type.equals("A") || type.equals("I")) { + transferType = type; + String typeName = type.equals("A") ? "ASCII" : "Binary"; + sendResponse(FTPResponse.COMMAND_OK, "Type set to " + typeName); + } else { + sendResponse(FTPResponse.COMMAND_NOT_IMPLEMENTED_FOR_PARAMETER, "Type not supported"); + } + } + + private void handlePasv() throws IOException { + if (!isAuthenticated) { + sendResponse(FTPResponse.NOT_LOGGED_IN, "Please login first"); + return; + } + + // Close previous data connection if exists + if (dataConnection != null) { + dataConnection.close(); + } + + dataConnection = new FTPDataConnection(); + + // Get server address from control socket + String serverAddress = controlSocket.getLocalAddress().getHostAddress(); + + if (dataConnection.openPassiveMode(controlSocket.getLocalAddress())) { + int port = dataConnection.getPassivePort(); + + // Format: h1,h2,h3,h4,p1,p2 + // where IP is h1.h2.h3.h4 and port is p1*256+p2 + String[] addressParts = serverAddress.split("\\."); + int p1 = port / 256; + int p2 = port % 256; + + String pasvResponse = String.format("Entering Passive Mode (%s,%s,%s,%s,%d,%d)", + addressParts[0], addressParts[1], addressParts[2], addressParts[3], p1, p2); + + sendResponse(FTPResponse.ENTERING_PASSIVE_MODE, pasvResponse); + Log.i(TAG, "Passive mode: " + serverAddress + ":" + port); + } else { + sendResponse(FTPResponse.CANNOT_OPEN_DATA_CONNECTION, "Cannot open passive mode"); + } + } + private void handleNoop() throws IOException { sendResponse(FTPResponse.COMMAND_OK, "OK"); } @@ -291,6 +382,9 @@ public class FTPSession implements Runnable { private void closeSession() { try { + if (dataConnection != null) { + dataConnection.close(); + } if (reader != null) reader.close(); if (writer != null) writer.close(); if (controlSocket != null && !controlSocket.isClosed()) {