Feat: 데이터 연결 및 Passive Mode 구현

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 02:53:48 +09:00
parent 7ec1073d14
commit 5b276a4d8a
2 changed files with 289 additions and 11 deletions

View File

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

View File

@@ -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.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()) {