聊聊第一個開源專案(內網穿透) - CProxy

會玩code發表於2022-03-06

文章首發:聊聊第一個開源專案 - CProxy 作者:會玩code

初衷

最近在學C++,想寫個專案練練手。對網路比較感興趣,之前使用過ngrok(GO版本的內網穿透專案),看了部分原始碼,想把自己的一些優化想法用C++實現一下,便有了這個專案。

專案介紹

CProxy是一個反向代理,使用者可在自己內網環境中啟動一個業務服務,並在同一網路下啟動CProxyClient,用於向CProxyServer註冊服務。CProxyClient和CProxyServer之間會建立一個隧道,外網可以通過訪問CProxyServer,資料轉發到CProxyClient,從而被業務服務接收到。實現內網服務被外網訪問。

專案地址

https://github.com/lzs123/CProxy.git

使用方法

bash build.sh
// 啟動服務端
{ProjectDir}/build/server/Server --proxy_port=8090 --work_thread_nums=4
(另一個終端) 
// 啟動客戶端
{ProjectDir}/build/client/Client --local_server=127.0.0.1:7777 --cproxy_server=127.0.0.1:8080

專案亮點

  • 使用epoll作為IO多路複用的實現
  • 資料轉發時,使用splice零拷貝,減少IO效能瓶頸
  • 資料連線和控制連線接耦,避免互相影響
  • 採用Reactor多執行緒模型,充分利用多核CPU效能

流程架構

角色

  1. LocalServer: 內網業務服務
  2. CProxyClient: CProxy客戶端,一般與LocalServer部署在一起,對接CProxyServer和InnerServer
  3. CProxyServer: CProxy服務端
  4. PublicClient: 業務客戶端

資料流

PublicClient先將請求打到CProxyServer,CProxyServer識別請求是屬於哪個CProxyClient,然後將資料轉發到CProxyClient,CProxyClient再識別請求是屬於哪個LocalServer的,將請求再轉發到LocalServer,完成資料的轉發。

工作流程

先介紹CProxyServer端的兩個概念:

  • Control:在CProxyServer中會維護一個ControlMap,一個Control對應一個CProxyClient,儲存CProxyClient的一些元資訊和控制資訊
  • Tunnel:每個Control中會維護一個TunnelMap,一個Tunnel對應一個LocalServer服務

在CProxyClient端,也會維護一個TunnelMap,每個Tunnel對應一個LocalServer服務,只不過Client端的Tunnel與Server端的Tunnel儲存的內容略有差異

啟動流程

CProxyServer
  1. 完成幾種工作執行緒的初始化。
  2. 監聽一個CtlPort,等待CProxyClient連線。
CProxyClient
  1. 完成對應執行緒的初始化。
  2. 然後連線Server的CtlPort,此連線稱為ctl_conn, 用於client和server之前控制資訊的傳遞。
  3. 請求註冊Control,獲取ctl_id。
  4. 最後再根據Tunnel配置檔案完成多個Tunnel的註冊。需要注意的是,每註冊一個Tunnel,Server端就會多監聽一個PublicPort,作為外部訪問LocalServer的入口。

資料轉發流程

  1. Web上的PublicClient請求CProxyServer上的PublicPort建立連線;CProxyServer接收連線請求,將public_accept_fd封裝成PublicConn。
  2. CProxyServer通過ctl_conn向client傳送NotifyClientNeedProxyMsg通知Client需要建立一個proxy。
  3. Client收到後,會分別連線LocalServer和CProxyServer:
    3.1. 連線LocalServer,將local_conn_fd封裝成LocalConn。
    3.2. 連線ProxyServer的ProxyPort,將proxy_conn_fd封裝成ProxyConn,並將LocalConn和ProxyConn繫結。
  4. CProxyServer的ProxyPort收到請求後,將proxy_accept_fd封裝成ProxyConn,將ProxyConn與PublicConn繫結。
  5. 此後的資料在PublicConn、ProxyConn和LocalConn上完成轉發傳輸。

連線管理

複用proxy連線

為了避免頻繁建立銷燬proxy連線,在完成資料轉發後,會將proxyConn放到空閒佇列中,等待下次使用。
proxy_conn有兩種模式 - 資料傳輸模式和空閒模式。在資料傳輸模式中,proxy_conn不會去讀取解析緩衝區中的資料,只會把資料通過pipe管道轉發到local_conn; 空閒模式時,會讀取並解析緩衝區中的資料,此時的資料是一些控制資訊,用於調整proxy_conn本身。

當有新publicClient連線時,會先從空閒列表中獲取可用的proxy_conn,此時proxy_conn處於空閒模式,CProxyServer端會通過proxy_conn向CProxyClient端傳送StartProxyConnReqMsg,
CLient端收到後,會為這個proxy_conn繫結一個local_conn, 並將工作模式置為資料傳輸模式。之後資料在這對proxy_conn上進行轉發。

資料連線斷開處理

close和shutdown的區別

  1. close
int close(int sockfd)

在不考慮so_linger的情況下,close會關閉兩個方向的資料流。

  1. 讀方向上,核心會將套接字設定為不可讀,任何讀操作都會返回異常;
  2. 輸出方向上,核心會嘗試將傳送緩衝區的資料傳送給對端,之後傳送fin包結束連線,這個過程中,往套接字寫入資料都會返回異常。
  3. 若對端還傳送資料過來,會返回一個rst報文。

注意:套接字會維護一個計數,當有一個程式持有,計數加一,close呼叫時會檢查計數,只有當計數為0時,才會關閉連線,否則,只是將套接字的計數減一。
2. shutdown

int shutdown(int sockfd, int howto)

shutdown顯得更加優雅,能控制只關閉連線的一個方向

  1. howto = 0 關閉連線的讀方向,對該套接字進行讀操作直接返回EOF;將接收緩衝區中的資料丟棄,之後再有資料到達,會對資料進行ACK,然後悄悄丟棄。
  2. howto = 1 關閉連線的寫方向,會將傳送緩衝區上的資料傳送出去,然後傳送fin包;應用程式對該套接字的寫入操作會返回異常(shutdown不會檢查套接字的計數情況,會直接關閉連線)
  3. howto = 2 0+1各操作一遍,關閉連線的兩個方向。

專案使用shutdown去處理資料連線的斷開,當CProxyServer收到publicClient的fin包(CProxyClient收到LocalServer的fin包)後,通過ctlConn通知對端,
對端收到後,呼叫shutdown(local_conn_fd/public_conn_fd, 2)關閉寫方向。等收到另一個方向的fin包後,將proxyConn置為空閒模式,並放回空閒佇列中。

在處理連結斷開和複用代理連結這塊遇到的坑比較多

  1. 控制對端去shutdown連線是通過ctl_conn去通知的,可能這一方向上對端的資料還沒有全部轉發完成就收到斷開通知了,需要確保資料全部轉發完才能呼叫shutdown去關閉連線。
  2. 從空閒列表中拿到一個proxy_conn後,需要傳送StartProxyConnReq,告知對端開始工作,如果此時對端的這一proxy_conn還處於資料傳輸模式,就會報錯了。

資料傳輸

資料在Server和Client都需進行轉發,將資料從一個連線的接收緩衝區轉發到另一個連線的傳送緩衝區。如果使用write/read系統呼叫,整個流程如下圖

資料先從核心空間複製到使用者空間,之後再呼叫write系統呼叫將資料複製到核心空間。每次系統呼叫,都需要切換CPU上下文,而且,兩次拷貝都需要CPU去執行(CPU copy),所以,大量的拷貝操作,會成為整個服務的效能瓶頸。

在CProxy中,使用splice的零拷貝方案,資料直接從核心空間的Source Socket Buffer轉移到Dest Socket Buffer,不需要任何CPU copy。

splice通過pipe管道“傳遞”資料,基本原理是通過pipe管道修改source socket buffer和dest socket buffer的實體記憶體頁

splice並不涉及資料的實際複製,只是修改了socket buffer的實體記憶體頁指標。

併發模型

CProxyClient和CProxyServer均採用多執行緒reactor模型,利用執行緒池提高併發度。並使用epoll作為IO多路複用的實現方式。每個執行緒都有一個事件迴圈(One loop per thread)。執行緒分多類,各自處理不同的連線讀寫。

CProxyServer端

為了避免業務連線處理影響到Client和Server之間控制資訊的傳遞。我們將業務資料處理與控制資料處理解耦。在Server端中設定了三種執行緒:

  1. mainThread: 用於監聽ctl_conn和proxy_conn的連線請求以及ctl_conn上的相關讀寫
  2. publicListenThread: 監聽並接收外來連線
  3. eventLoopThreadPool: 執行緒池,用於處理public_conn和proxy_conn之間的資料交換。

CProxyClient端

client端比較簡單,只有兩種執行緒:

  1. mainThread: 用於處理ctl_conn的讀寫
  2. eventLoopThreadPool: 執行緒池,用於處理proxy_conn和local_conn之間的資料交換

遺留問題(未完待續。。)

在使用ab壓測時,在完成了幾百個轉發後,就卡住了,通過tcpdump抓包發現客戶端使用A埠連線,但服務端accept後列印的客戶端埠是B。
資料流在【publicClient->CProxyServer->CProxyClient->LocalServer】是正常的;
但回包方向【LocalServer->CProxyClient->CProxyServer-❌->publicClient】,目前還沒有找到分析方向。。。

寫在最後

喜歡本文的朋友,歡迎關注公眾號「會玩code」,專注大白話分享實用技術

相關文章