在實現了HTTP服務器之後,本人打算再實現一個FTP服務器。由於FTP協議與HTTP一樣都位於應用層,所以實現原理也類似。在這裏把實現的原理和源碼分享給大家。
首先需要明確的是FTP協議中涉及命令端口和數據端口,即每個客戶端通過命令端口向服務器發送命令(切換目錄、刪除文件等),通過數據端口從服務器接收數據(目錄列表、下載上傳文件等)。這就要求對每個連接都必須同時維護兩個端口,如果使用類似於上一篇文章中的多路IO就會複雜很多,因此本文采用了類似Apache的多進程機制,即對每個連接創建一個單獨的進程進行管理。
接下來簡要說明一下FTP協議的通信流程,Ftp服務器向客戶端發送的消息主要由兩部分組成,第一部分是狀態碼(與HTTP類似),第二部分是具體內容(可以爲空),兩部分之間以空格分隔,如“220 TS FTP Server ready”就告訴了客戶端已經連接上了服務器;客戶端向服務器發送的命令也由兩部分組成,第一部分是命令字符串,第二部分是具體內容(可以爲空),兩部分之間也以空格分隔,如“USER anonymous”就指定了登錄FTP服務器的用戶名。以一個登錄FTP服務器並獲取目錄列表的流程爲例:
220 TS FTP Server ready...
USER anonymous
331 Password required for anonymous
PASS [email protected]
530 Not logged in,password error.
QUIT
221 Goodbye
USER zhaoxy
331 Password required for zhaoxy
PASS 123
230 User zhaoxy logged in
SYST
215 UNIX Type: L8
PWD
257 "/" is current directory.
TYPE I
200 Type set to I
PASV
227 Entering Passive Mode (127,0,0,1,212,54)
SIZE /
550 File not found
PASV
227 Entering Passive Mode (127,0,0,1,212,56)
CWD /
250 CWD command successful. "/" is current directory.
LIST -l
150 Opening data channel for directory list.
16877 8 501 20 272 4 8 114 .
16877 29 501 20 986 4 8 114 ..
33188 1 501 20 6148 3 28 114 .DS_Store
16877 4 501 20 136 2 27 114 css
33279 1 501 20 129639543 6 14 113 haha.pdf
16877 11 501 20 374 2 27 114 images
33261 1 501 20 11930 3 9 114 index.html
16877 6 501 20 204 2 28 114 js
226 Transfer ok.
QUIT
221 Goodbye
在一個客戶端連接到服務器後,首先服務器要向客戶端發送歡迎信息220,客戶端依此向服務器發送用戶名和密碼,服務器校驗之後如果失敗則返回530,成功則返回230。一般所有的客戶端第一次連接服務器都會嘗試用匿名用戶進行登錄,登錄失敗再向用戶詢問用戶名和密碼。接下來,客戶端會與服務器確認文件系統的類型,查詢當前目錄以及設定傳輸的數據格式。
FTP協議中主要有兩種格式,二進制和ASCII碼,兩種格式的主要區別在於換行,二進制格式不會對數據進行任何處理,而ASCII碼格式會將回車換行轉換爲本機的回車字符,比如Unix下是\n,Windows下是\r\n,Mac下是\r。一般圖片和執行文件必須用二進制格式,CGI腳本和普通HTML文件必須用ASCII碼格式。
在確定了傳輸格式之後,客戶端會設定傳輸模式,Passive被動模式或Active主動模式。在被動模式下,服務器會再創建一個套接字綁定到一個空閒端口上並開始監聽,同時將本機ip和端口號(h1,h2,h3,h4,p1,p2,其中p1*256+p2等於端口號)發送到客戶端。當之後需要傳輸數據的時候,服務器會通過150狀態碼通知客戶端,客戶端收到之後會連接到之前指定的端口並等待數據。傳輸完成之後,服務器會發送226狀態碼告訴客戶端傳輸成功。如果客戶端不需要保持長連接的話,此時可以向服務器發送QUIT命令斷開連接。在主動模式下,流程與被動模式類似,只是套接字由客戶端創建並監聽,服務器連接到客戶端的端口上進行數據傳輸。
以下是main函數中的代碼:
#include <iostream>
#include "define.h"
#include "CFtpHandler.h"
#include <sys/types.h>
#include <sys/socket.h>
int main(int argc, const char * argv[])
{
int port = 2100;
int listenFd = startup(port);
//ignore SIGCHLD signal, which created by child process when exit, to avoid zombie process
signal(SIGCHLD,SIG_IGN);
while (1) {
int newFd = accept(listenFd, (struct sockaddr *)NULL, NULL);
if (newFd == -1) {
//when child process exit, it'll generate a signal which will cause the parent process accept failed.
//If happens, continue.
if (errno == EINTR) continue;
printf("accept error: %s(errno: %d)\n",strerror(errno),errno);
}
//timeout of recv
struct timeval timeout = {3,0};
setsockopt(newFd, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout,sizeof(struct timeval));
int pid = fork();
//fork error
if (pid < 0) {
printf("fork error: %s(errno: %d)\n",strerror(errno),errno);
}
//child process
else if (pid == 0) {
//close useless socket
close(listenFd);
send(newFd, TS_FTP_STATUS_READY, strlen(TS_FTP_STATUS_READY), 0);
CFtpHandler handler(newFd);
int freeTime = 0;
while (1) {
char buff[256];
int len = (int)recv(newFd, buff, sizeof(buff), 0);
//connection interruption
if (len == 0) break;
//recv timeout return -1
if (len < 0) {
freeTime += 3;
//max waiting time exceed
if (freeTime >= 30) {
break;
}else {
continue;
}
}
buff[len] = '\0';
//reset free time
freeTime = 0;
if (handler.handleRequest(buff)) {
break;
}
}
close(newFd);
std::cout<<"exit"<<std::endl;
exit(0);
}
//parent process
else {
//close useless socket
close(newFd);
}
}
close(listenFd);
return 0;
}
代碼中先創建了套接字並綁定到指定端口上,然後進入循環開始監聽端口。每監聽到一個新的連接就fork出一個子進程。子進程向客戶端發送歡迎信息後進入循環處理客戶端發送過來的命令,直到收到QUIT命令或者連接超時退出循環。以上代碼中需要注意三個地方,一是子進程在退出之後會向父進程發送SIGCHLD信號,如果父進程不進行處理(調用wait或忽略)就會導致子進程變爲殭屍進程,本文中採用的是忽略的方式;二是accept函數在父進程收到信號時會直接返回,因此需要判斷如果返回是由於信號則繼續循環,不fork,否則會無限創建子進程;三是在fork之後需要將不使用的套接字關閉,比如父進程需要關閉新的連接套接字,而子進程需要關閉監聽套接字,避免套接字無法完全關閉。
最後通過CFtpHandler類中的handleRequest方法處理客戶端的命令,部分代碼如下:
//handle client request
bool CFtpHandler::handleRequest(char *buff) {
stringstream recvStream;
recvStream<<buff;
cout<<buff;
string command;
recvStream>>command;
bool isClose = false;
string msg;
//username
if (command == COMMAND_USER) {
recvStream>>username;
msg = TS_FTP_STATUS_PWD_REQ(username);
}
//password
else if (command == COMMAND_PASS) {
recvStream>>password;
if (username == "zhaoxy" && password == "123") {
msg = TS_FTP_STATUS_LOG_IN(username);
}else {
msg = TS_FTP_STATUS_PWD_ERROR;
}
}
//quit
else if (command == COMMAND_QUIT) {
msg = TS_FTP_STATUS_BYE;
isClose = true;
}
//system type
else if (command == COMMAND_SYST) {
msg = TS_FTP_STATUS_SYSTEM_TYPE;
}
//current directory
else if (command == COMMAND_PWD) {
msg = TS_FTP_STATUS_CUR_DIR(currentPath);
}
//transmit type
else if (command == COMMAND_TYPE) {
recvStream>>type;
msg = TS_FTP_STATUS_TRAN_TYPE(type);
}
//passive mode
else if (command == COMMAND_PASSIVE) {
int port = 0;
if (m_dataFd) {
close(m_dataFd);
}
m_dataFd = startup(port);
stringstream stream;
stream<<TS_FTP_STATUS_PASV<<port/256<<","<<port%256<<")";
msg = stream.str();
//active passive mode
m_isPassive = true;
}
//active mode
else if (command == COMMAND_PORT) {
string ipStr;
recvStream>>ipStr;
char ipC[32];
strcpy(ipC, ipStr.c_str());
char *ext = strtok(ipC, ",");
m_clientPort = 0; m_clientIp = 0;
m_clientIp = atoi(ext);
int count = 0;
//convert string to ip address and port number
//be careful, the ip should be network endianness
while (1) {
if ((ext = strtok(NULL, ","))==NULL) {
break;
}
switch (++count) {
case 1:
case 2:
case 3:
m_clientIp |= atoi(ext)<<(count*8);
break;
case 4:
m_clientPort += atoi(ext)*256;
break;
case 5:
m_clientPort += atoi(ext);
break;
default:
break;
}
}
msg = TS_FTP_STATUS_PORT_SUCCESS;
}
//file size
else if (command == COMMAND_SIZE) {
recvStream>>fileName;
string filePath = ROOT_PATH+currentPath+fileName;
long fileSize = filesize(filePath.c_str());
if (fileSize) {
stringstream stream;
stream<<TS_FTP_STATUS_FILE_SIZE<<fileSize;
msg = stream.str();
}else {
msg = TS_FTP_STATUS_FILE_NOT_FOUND;
}
}
//change directory
else if (command == COMMAND_CWD) {
string tmpPath;
recvStream>>tmpPath;
string dirPath = ROOT_PATH+tmpPath;
if (isDirectory(dirPath.c_str())) {
currentPath = tmpPath;
msg = TS_FTP_STATUS_CWD_SUCCESS(currentPath);
}else {
msg = TS_FTP_STATUS_CWD_FAILED(currentPath);
}
}
//show file list
else if (command == COMMAND_LIST || command == COMMAND_MLSD) {
string param;
recvStream>>param;
msg = TS_FTP_STATUS_OPEN_DATA_CHANNEL;
sendResponse(m_connFd, msg);
int newFd = getDataSocket();
//get files in directory
string dirPath = ROOT_PATH+currentPath;
DIR *dir = opendir(dirPath.c_str());
struct dirent *ent;
struct stat s;
stringstream stream;
while ((ent = readdir(dir))!=NULL) {
string filePath = dirPath + ent->d_name;
stat(filePath.c_str(), &s);
struct tm tm = *gmtime(&s.st_mtime);
//list with -l param
if (param == "-l") {
stream<<s.st_mode<<" "<<s.st_nlink<<" "<<s.st_uid<<" "<<s.st_gid<<" "<<setw(10)<<s.st_size<<" "<<tm.tm_mon<<" "<<tm.tm_mday<<" "<<tm.tm_year<<" "<<ent->d_name<<endl;
}else {
stream<<ent->d_name<<endl;
}
}
closedir(dir);
//send file info
string fileInfo = stream.str();
cout<<fileInfo;
send(newFd, fileInfo.c_str(), fileInfo.size(), 0);
//close client
close(newFd);
//send transfer ok
msg = TS_FTP_STATUS_TRANSFER_OK;
}
//send file
else if (command == COMMAND_RETRIEVE) {
recvStream>>fileName;
msg = TS_FTP_STATUS_TRANSFER_START(fileName);
sendResponse(m_connFd, msg);
int newFd = getDataSocket();
//send file
std::ifstream file(ROOT_PATH+currentPath+fileName);
file.seekg(0, std::ifstream::beg);
while(file.tellg() != -1)
{
char *p = new char[1024];
bzero(p, 1024);
file.read(p, 1024);
int n = (int)send(newFd, p, 1024, 0);
if (n < 0) {
cout<<"ERROR writing to socket"<<endl;
break;
}
delete p;
}
file.close();
//close client
close(newFd);
//send transfer ok
msg = TS_FTP_STATUS_FILE_SENT;
}
//receive file
else if (command == COMMAND_STORE) {
recvStream>>fileName;
msg = TS_FTP_STATUS_UPLOAD_START;
sendResponse(m_connFd, msg);
int newFd = getDataSocket();
//receive file
ofstream file;
file.open(ROOT_PATH+currentPath+fileName, ios::out | ios::binary);
char buff[1024];
while (1) {
int n = (int)recv(newFd, buff, sizeof(buff), 0);
if (n<=0) break;
file.write(buff, n);
}
file.close();
//close client
close(newFd);
//send transfer ok
msg = TS_FTP_STATUS_FILE_RECEIVE;
}
//get support command
else if (command == COMMAND_FEAT) {
stringstream stream;
stream<<"211-Extension supported"<<endl;
stream<<COMMAND_SIZE<<endl;
stream<<"211 End"<<endl;;
msg = stream.str();
}
//get parent directory
else if (command == COMMAND_CDUP) {
if (currentPath != "/") {
char path[256];
strcpy(path, currentPath.c_str());
char *ext = strtok(path, "/");
char *lastExt = ext;
while (ext!=NULL) {
ext = strtok(NULL, "/");
if (ext) lastExt = ext;
}
currentPath = currentPath.substr(0, currentPath.length()-strlen(lastExt)-1);
}
msg = TS_FTP_STATUS_CDUP(currentPath);
}
//delete file
else if (command == COMMAND_DELETE) {
recvStream>>fileName;
//delete file
if (remove((ROOT_PATH+currentPath+fileName).c_str()) == 0) {
msg = TS_FTP_STATUS_DELETE;
}else {
printf("delete error: %s(errno: %d)\n",strerror(errno),errno);
msg = TS_FTP_STATUS_DELETE_FAILED;
}
}
//other
else if (command == COMMAND_NOOP || command == COMMAND_OPTS){
msg = TS_FTP_STATUS_OK;
}
sendResponse(m_connFd, msg);
return isClose;
}
以上代碼針對每種命令進行了不同的處理,在這裏不詳細說明。需要注意的是,文中採用的if-else方法判斷命令效率是很低的,時間複雜度爲O(n)(n爲命令總數),有兩種方法可以進行優化,一是由於FTP命令都是4個字母組成的,可以將4個字母的ascii碼拼接成一個整數,使用switch進行判斷,時間複雜度爲O(1);二是類似Http服務器中的方法,將每個命令以及相應的處理函數存到hashmap中,收到一個命令時可以通過hash直接調用相應的函數,時間複雜度同樣爲O(1)。
另外,以上代碼中的PORT命令處理時涉及對ip地址的解析,需要注意本機字節順序和網絡字節順序的區別,如127.0.0.1轉換成整數應逆序轉換,以網絡字節順序存到s_addr變量中。
以上源碼已經上傳到GitHub中,感興趣的朋友可以前往下載。