5. 用Rust手把手編寫一個Proxy(代理), 通訊協議建立, 為內網穿透做準備

問蒙服務框架發表於2023-09-28

用Rust手把手編寫一個Proxy(代理), 通訊協議建立, 為內網穿透做準備

專案 ++wmproxy++

gite: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

什麼是通訊協議?

在tcp的流傳輸過程中,可以看做是一堆的位元組的集合體,是一種“流”式協議,就像河裡的水,中間沒有邊界。或者好比不懂漢語的來看古文,因為古文裡沒有任何的句讀,不知何時另起一行。那我們如何正確的做到拆包解包,保證資料格式的正確呢?

以下是客戶端傳送兩個30位元組的包(P1及P2),服務端讀取資料可能讀出來的可能

gantt
    title 粘包的可能,例每個包30位元組
    %% This is a comment
    dateFormat X
    axisFormat %s
    section 示例1
        P2          :a1, 1, 30
        P1          :after a1, 60
    section 示例2
        P2,P1 :1,60
    section 示例3
        P2部分          :a3, 1, 20
        P2部分P1全部    :after a3, 60
    section 示例4
        P2全部P1部分    :a4, 1, 40
        P1部分          :after a4, 60

若沒有事先約定好格式,在服務端部分無法正確的解析出P1包和P2包,也就意味著無法理解客戶端發的內容。若此時我們約定每個包的大小固定為30位元組,那麼2,3,4三種可能不管收到多少,都必須等待30位元組填充完畢後解析出P1,剩餘的資料待待60位元組接收完畢後解析P2包

粘包拆包常見的解決方案

對於粘包和拆包問題,常見的解決方案有四種:

  • 傳送端將每個包都封裝成固定的長度,比如512位元組大小。如果不足512位元組可透過補0或空等進行填充到指定長度;
  • 傳送端在每個包的末尾使用固定的分隔符,例如\r\n。如果發生拆包需等待多個包傳送過來之後再找到其中的\r\n進行合併;例如,Redis協議,每一行的結尾都是CRLF,在碰到結尾的時候才進行轉發;
  • 將訊息分為頭部和訊息體,頭部中儲存整個訊息的長度,只有讀取到足夠長度的訊息之後才算是讀到了一個完整的訊息,例如HTTP2協議,固定先讀3個位元組的長度,9個位元組的長度頭資訊;
  • 透過自定義協議進行粘包和拆包的處理。
在此的解決方案

選擇了分為頭部和訊息體方案,頭部分為8個位元組,然後前3個位元組表示包體的長度,單包支援長度為8-167777215也就是16m的大小,足夠應對大多數情況。

網路的拓撲圖

因為每個連結的處理函式均在不同的協程裡,所以這裡用了Sender/Receiver來同步資料。

flowchart TD A[中心客戶端/CenterClient]<-->|tls加密連線或普通連線|B[中心服務端/CenterServer] C[客戶端連結]<-->|Sender/Receiver|A B<-->|Sender/Receiver|D[服務端連結]

協議的分類

協議相關的類均在prot目錄下面,統一對外的為列舉ProtFrame,類的定義如下

pub enum ProtFrame {
    /// 收到新的Socket連線
    Create(ProtCreate),
    /// 收到舊的Socket連線關閉
    Close(ProtClose),
    /// 收到Socket的相關資料
    Data(ProtData),
}

主要涉及類的編碼及解析在方法encode,parse,定義如下

/// 把位元組流轉化成資料物件
pub fn parse<T: Buf>(
    header: ProtFrameHeader,
    buf: T,
) -> ProxyResult<ProtFrame> {
    
}

/// 把資料物件轉化成位元組流
pub fn encode<B: Buf + BufMut>(
    self,
    buf: &mut B,
) -> ProxyResult<usize> {
    
}
訊息的包頭

任何訊息優先獲取包頭資訊,從而才能進行相應的型別解析,類為ProtFrameHeader,定義如下,總共8個位元組

pub struct ProtFrameHeader {
    /// 包體的長度, 3個位元組, 最大為16m
    pub length: u32,
    /// 包體的型別, 如Create, Data等
    kind: ProtKind,
    /// 包體的標識, 如是否為響應包等
    flag: ProtFlag,
    /// 3個位元組, socket在記憶體中相應的控制程式碼, 客戶端發起為單數, 服務端發起為雙數
    sock_map: u32,
}
訊息型別的定義

暫時目前定義三種型別,Create, Close, Data

  • Socket建立,類為ProtCreate
/// 新的Socket連線請求, 
/// 接收方建立一個虛擬連結來對應該Socket的讀取寫入
#[derive(Debug)]
pub struct ProtCreate {
    sock_map: u32,
    mode: u8,
    domain: Option<String>,
}
  • Socket關閉,類為ProtClose
/// 舊的Socket連線關閉, 接收到則關閉掉當前的連線
#[derive(Debug)]
pub struct ProtClose {
    sock_map: u32,
}
  • Socket資料包,類為ProtData
/// Socket的資料訊息包
#[derive(Debug)]
pub struct ProtData {
    sock_map: u32,
    data: Binary,
}

一個資料包的自白

我是一段資料,我要去找伺服器獲得詳細的資料

首先我得和伺服器先能溝通上,建立一條可以通訊的線

flowchart TD A[我]-->|請求連線建立|B[客戶端代理] B-->|把連結交由|C[中心客戶端] C-->|生成sock_map如1,併傳送ProtCreate|D[中心服務端] D-->|根據ProtCreate建立與sock_map對應的唯一id|E[虛擬TCP連線] E-->|根據相應資訊連線到服務端|F[服務端]

此時我已經和服務端構建起了一條通訊渠道,接下來我要和他傳送資料了

flowchart TD A[我]-->|傳送位元組資料|B[客戶端代理] B-->|讀出資料交由|C[中心客戶端] C<-->|加工成ProtData傳送|D[中心服務端] D-->|根據ProtData的sock_map傳送給對應|E[虛擬TCP連線] E-->|解析成資料流寫入|F[服務端] F-->|把資料流返回|E E-->|讀出資料交由|D C-->|根據ProtData的sock_map傳送給對應|B B-->|解析成資料流寫入|A

至此一條我與服務端已經可以說悄悄話啦。

內網穿透

內網穿秀本質上從中心服務端反向交由中心客戶端構建起一條通訊渠道,如今資料協議已經建立,可由服務端推送資料到客戶端進行處理,後續實現請看下篇

相關文章