【项目介绍】FTP服务器
文章目錄
- MiniFTP / FTP服務器
- 開發語言
- 開發環境
- 項目介紹
- 項目特點
- 項目鏈接
- 使用實例
 
- 架構介紹與難點分析
- 系統架構
- 連接模式
- 特定情景
- 主動模式
- 被動模式
 
- 進程組的設計
- 設計原因
- 內部通信機制
 
- 用戶鑒權
- 空閑斷開
- 限制連接
 
MiniFTP / FTP服務器
開發語言
C
開發環境
CentOS7、LeapFTP、vim、gcc、gdb、git、Makefile
項目介紹
MiniFTP是一個FTP服務器軟件,通過使用MiniFTP能夠快速的將任何一臺PC設置成為一個簡易的FTP服務器,任何一臺PC都可以通過使用FTP協議來與服務器進行連接,進行文件的訪問、存儲、管理等,實現信息共享的功能。
項目特點
- 實現FTP命令如:USER、PASS、PORT、PASV、TYPE、LIST、SYST、FEAT、PWD、SIZE、CWD、RNFR/RNTO、STOR、RETR、MKD、RMD、CWD、QUIT、DELE、REST、CDUP
- 具有用戶鑒權登錄、斷點續傳(續載)、傳輸限速、配置文件解析等功能
- 實現主動和被動兩種連接模式,通過nobody進程協助ftp服務進程創建數據連接與特權端口綁定。
- 實現控制連接和數據連接的空閑斷開,緩解了服務器的壓力。
- 通過哈希表實現最大連接數、每ip連接數的限制,防止大量的惡意訪問。
項目鏈接
github
使用實例
這里借助Windows下的FTP客戶端LeapFTP來進行演示
 連接
 
下載
 
斷點續載
 
架構介紹與難點分析
系統架構
為了保證各個客戶端之間具有獨立性以及健壯性,我選擇了使用多進程來實現。
理由如下:
對于每一個客戶端連接,都會通過一個**進程組(nobody進程、ftp服務進程)**來進行管理
至于為什么要通過進程組來進行通信,就需要先講講連接模式
連接模式
因為在網絡通信時,可能會因為主服務器或者客戶端受到防火墻或者NAT的影響,導致通信的某一端無法被連接,所以FTP提供了主動連接模式和被動連接模式
特定情景
之所以準備了主動和被動兩種連接模式,是考慮到了數據連接時可能會因為防火墻或者NAT轉換的原因導致連接的建立失敗
為什么控制連接不會建立失敗,而數據連接會呢?
因為NAT會主動記錄由內部發送外部的連接信息,而控制連接的是由客戶端向服務器端主動發起的,所以這條連接可以成功的建立。
而數據連接建立時,假設客戶端啟用XX端口來接受連接,通信外網時由于私網地址經過了NAT轉換為公網地址,而服務器的20端口會主動向NAT的XX端口發起連接請求,但是NAT可能并沒有啟用XX端口,因此會導致連接被拒絕。或者可能因為防火墻中并沒有設置該端口的開放權限,導致通往該端口的連接直接會被拒絕。客戶端也同理,如下圖
? 客戶端受到防火墻或者NAT的干擾
? 服務器受到防火墻或者NAT的干擾
所以設計了主動****和被動兩種連接模式來解決上面那兩種情況
主動模式
主動模式用于解決服務器受到防火墻或者NAT干擾的情況,既然客戶端的連接請求會被拒絕,那就由服務器來主動連接客戶端
? 此時,即使服務器這邊存在干擾,也能通過主動模式來成功建立起數據連接
連接流程
被動模式
被動連接則是用來解決客戶端受到防火墻或者NAT干擾的情況,此時服務器發往客戶端的請求會被拒絕,那么此時就讓客戶端來主動連接,服務器被動的接收連接就行。
? 此時,即使客戶端這邊存在干擾,也能通過被動模式來成功建立起數據連接
連接流程
并且主動連接和被動連接還有一個關鍵點,主動連接時需要客戶端提供自己的IP地址和端口號,而服務器什么并沒有提供關于數據連接的信息,所以此時服務器得到了安全保障,主動連接對服務器有利
而被動連接時服務器提供了數據連接的IP地址和端口號,而客戶端并沒暴露信息,所以被動連接對客戶端有利。
為了保證客戶端的使用安全,大部分FTP服務器都會默認使用被動連接模式。
進程組的設計
對于MiniFTP,我選擇使用多進程來實現,并且每一個連接都會由一個nobody進程和ftp服務進程構成的進程組來進行管理。
設計原因
從上面的連接模式可以看到,在進行主動連接的時候服務器會創建一個連接套接字來綁定20端口(協議規定),然后主動向客戶端建立起數據連接。此時就出現了一個問題,普通的用戶沒有權限綁定特權端口(1024以內的端口)。
針對這個問題,我一開始想的方法是先以ROOT權限來進行特權端口的綁定,然后再將其轉為普通用戶進程,經過閱讀相關博客和詢問老師,我發現經過這樣一個升級——綁定——降級的過程,可能會導致不安全的情況。
不僅如此,無論是主動模式還是被動模式,套接字的創建、監聽、特權端口綁定等這些會涉及到內核的相關操作,如果放到FTP的服務進程中,都會導致不安全的情況出現。
所以我想到了另外一種設計方案,再創建一個nobody進程,通過setcap()來給予它相關的權限,使得它此時的權限剛好能夠滿足對應操作(普通用戶之上,root用戶之下),并將所有涉及權限的操作全部交付給nobody進程來進行操作。
此時的nobody進程只會服務FTP服務進程,它不會接收任何來自客戶端的請求,它的操作如下
- 協助FTP服務進程進行數據連接的管理
- 協助FTP服務進程進行特權端口的綁定
內部通信機制
由于nobody進程和FTP服務進程為父子進程,所有可以考慮使用匿名管道(pipe)來進行進程間的通信,但是由于匿名管道的通信是半雙工通信(單向通信),一次只能由一方進行讀和寫,所以對于雙方的一次數據通信就要進行兩次pipe,使得代碼變得復雜。而在unix域下有著更好的機制,就是socketpair(),socketpair與管道的機制相同,但是socketpair是全雙工通信(雙向通信),支持雙方同時進行的讀和寫。
void priv_sock_init(session_t *sess); void priv_sock_close(session_t *sess); void priv_sock_set_parent_context(session_t *sess); void priv_sock_set_child_context(session_t *sess); void priv_sock_send_cmd(int fd, char cmd); char priv_sock_recv_cmd(int fd); void priv_sock_send_result(int fd, char res); char priv_sock_recv_result(int fd); void priv_sock_send_int(int fd, int the_int); int priv_sock_recv_int(int fd); void priv_sock_send_buf(int fd, const char *buf, unsigned int len); void priv_sock_recv_buf(int fd, char *buf, unsigned int len); void priv_sock_send_fd(int sock_fd, int fd); int priv_sock_recv_fd(int sock_fd);在進程組內部的通信中,實現了對結果、命令、字符、字符串、整型、描述符等格式的傳輸函數。其中其他的都十分簡單,但是文件描述符的傳輸則有點麻煩
因為文件描述符并不是一個簡單的整型傳輸,由于兩個進程有著不同的文件描述符表,而此時則需要將一個進程中的文件描述符傳給另一個進程的文件描述符表中。
這時可以借助系統函數來實現
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags); ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);struct msghdr {void *msg_name; /* 目的IP地址 */socklen_t msg_namelen; /* 地址長度 */struct iovec *msg_iov; /* 指定的內存緩沖區 */size_t msg_iovlen; /* 緩沖區的長度 */void *msg_control; /* 輔助數據 */size_t msg_controllen; /* 指向cmsghdr結構,用于控制信息字節數 */int msg_flags; /* 描述接收到的消息的標志 */ };struct cmsghdr {socklen_t cmsg_len; /* 計算cmsghdr頭結構加上附屬數據大小 */int cmsg_level; /* 發起協議 */int cmsg_type; /*協議特定類型 */ };//獲得指向與msghadr結構關聯的第一個cmsghdr結構 struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *msgh);//計算 cmsghdr 頭結構加上附屬數據大小,并包括對其字段和可能的結尾填充字符 size_t CMSG_SPACE(size_t length);//計算 cmsghdr 頭結構加上附屬數據大小 size_t CMSG_LEN(size_t length);//返回一個指針和cmsghdr結構關聯的數據 unsigned char *CMSG_DATA(struct cmsghdr *cmsg);上述函數的使用在這里就不多介紹,可以通過查詢man手冊或者閱讀相關博客來進行了解
nobody進程的工作流程
基本流程
- 將當前用戶從root用戶切換為nobody用戶,并且通過setcap()來提升對應權限
- 循環等待FTP服務進程發送來的命令
——分支——
#define PRIV_SOCK_GET_DATA_SOCK 1 #define PRIV_SOCK_PASV_ACTIVE 2 #define PRIV_SOCK_PASV_LISTEN 3 #define PRIV_SOCK_PASV_ACCEPT 4主動連接
- 接收到PRIV_SOCK_GET_DATA_SOCK命令
- 調用privop_pasv_recv_data_sock創建數據連接套接字,綁定20端口,并將創建好的套接字發送給FTP服務進程
被動連接
- 接收到PRIV_SOCK_PASV_LISTEN命令
- 調用privop_pasv_listen,創建一個監聽套接字,綁定一個臨時端口,然后將IP地址和端口號發送給FTP服務進程,然后FTP服務進程會將地址信息發送給客戶端,被動接收連
- 當客戶端發起連接時,FTP服務進程就會向nobody進程發送PRIV_SOCK_PASV_ACCEPT命令
- nobody調用privop_pasv_accept來接收連接,并將連接后的數據連接描述符發送給FTP服務進程
用戶鑒權
對于Linux的服務器來說,每一個賬戶都是Linux下的用戶。所以對賬號的登陸驗證,就是通過去對比該用戶的密碼與輸入的密碼是否一致。
那么接下來就應該確認賬號和密碼是否正確。
首先,我們需要查找用戶輸入的賬號是否存在,畢竟賬號不存在,就根本沒有鑒定密碼的必要。
我們可以通過用戶名,使用struct passwd *getpwnam(const char *name)這個函數來查找到對應用戶的信息,并且返回passwd結構的用戶信息,并且將passwd中的uid(用戶id)保存到會話信息中
struct passwd {char *pw_name; /* username */char *pw_passwd; /* user password */uid_t pw_uid; /* user ID */gid_t pw_gid; /* group ID */char *pw_gecos; /* user information */char *pw_dir; /* home directory */char *pw_shell; /* shell program */ };接著,就需要驗證密碼。
但是在Linux下,為了保證用戶的安全,所有的密碼都經過了加密后與用戶名一起放在了影子文件中,并且加密的算法是單向的,無法進行解密
那么我們就需要通過用戶名來獲取到影子文件中的內容,可以使用函數struct spwd *getspnam(const char *name)來使用用戶名來查詢到對應的影子信息,這個影子信息存儲在spwd結構體中
struct spwd {char *sp_namp; /* Login name */char *sp_pwdp; /* Encrypted password */long sp_lstchg; /* Date of last change(measured in days since1970-01-01 00:00:00 +0000 (UTC)) */long sp_min; /* Min # of days between changes */long sp_max; /* Max # of days between changes */long sp_warn; /* # of days before password expiresto warn user to change it */long sp_inact; /* # of days after password expiresuntil account is disabled */long sp_expire; /* Date when account expires(measured in days since1970-01-01 00:00:00 +0000 (UTC)) */unsigned long sp_flag; /* Reserved */ };通過訪問spwd中的sp_pwdp參數,就可以獲取到加密后的密碼。
接下來就要思考如何進行密碼的比對了,明文和密文肯定是無法直接比對的,那就需要將他們先轉換為同一種格式。而Linux的加密算法又是單向的,無法將其解密,那我們就反其道而行之,將明文進行加密后再與密文進行對比。
我們可以借助char *crypt(const char *key, const char *salt)這個函數來進行加密
其中的key為需要加密的明文,而salt為加密的密鑰。因為salt會默認使用DES加密算法(會根據salt前幾位的xxx中的x來修改加密方式)進行加密,并且在DES加密時會只提取salt的前兩個字符作為密鑰進行加密,多余的丟棄。而加密后取得的密文的前兩位也就是這個密鑰。
所以,我們可以直接將影子文件中的密碼作為密鑰進行加密,然后加密結束后判斷相同的密鑰加密后的明文是否與影子文件中的密碼一致,如果一致則說明密碼正確。
空閑斷開
我們需要對某些長時間無操作的不活躍客戶端進行斷開操作,來減輕服務器的壓力,騰出空間來為其他活躍用戶服務
那么如何設計這個功能呢?我一開始時想到可以設計一個定時器,定時器會一直監控進程是否運作,如果長時間無活動則會調用一個回調函數來通知斷開進程。但是這樣的一個定時器實現起來并不方便,并且如果由服務器來對一個進程進行監控和維護不僅會增加服務器壓力,還會使整體流程更加混亂。
考慮到上述問題,就想到讓操作系統來代為管理,而正好,sigalrm信號剛好就符合我的需求。
但是,又有另外一個問題,當客戶端在下載和上傳的時候,就會處于一個長時間的I/O阻塞,這個時候就會可能被誤判為無操作而被斷開,所以針對數據連接和控制連接來對信號進行分開處理
對于控制連接
- 如果當前沒有在傳輸,則斷開連接
- 如果當前在傳輸中,則忽略本次,重新進行控制連接斷開計時。
對于數據連接
- 當啟動數據連接計時的時候,停止控制連接的計時,等到傳輸結束后再恢復
- 當連接斷開時先關閉讀端,然后將連接斷開的響應碼發送給客戶端,再將寫端斷開
限制連接
為了防止有大量的惡意連接和以及同一IP下大量連接帶來的服務器壓力,需要考慮對總連接數以及每IP連接數進行限制。
首先,我們要考慮如何監控一個連接的創建與斷開。
創建很簡單,只需要在其建立起控制連接的時候進行記錄即可,而刪除的時候就稍微有點麻煩,我們需要注冊對子進程退出的sigchld信號的處理方法,當有進程退出時,就說明有一個連接進行斷開。
接著。就需要思考如何建立起IP與連接數的映射關系。
我們可以考慮使用鍵值對的模型,利用哈希表來進行一個映射,記錄下每個IP地址的連接數。
但是問題來了,雖然在創建的時候我們可以通過訪問accept()時接收的sockaddr來知道某個IP地址創建了一個新連接。但是由于退出的時候我們直接捕獲了sigchld信號,并沒有方法確認這個退出的進程屬于哪個id地址。
經過思考后,我選擇使用兩個哈希表來解決這個問題。第一個哈希表用來建立起進程PID與IP地址的映射關系,第二個哈希表用來建立起IP地址與連接數的映射關系。
這樣連接數的計算流程就如下
其他具體的實現流程請參考源代碼,在這里就不多贅述了,本博客只介紹了其中比較關鍵且難理解的地方與整體的邏輯架構設計思路
github鏈接
總結
以上是生活随笔為你收集整理的【项目介绍】FTP服务器的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: Redis 多机服务 : 主从同步、哨兵
- 下一篇: 【项目介绍】搜索引擎
