24. 從零用Rust編寫正反向代理,細說HTTP行為中的幾種定時器

2023-11-08 12:01:11

wmproxy

wmproxy已用Rust實現http/https代理, socks5代理, 反向代理, 靜態檔案伺服器,四層TCP/UDP轉發,內網穿透,後續將實現websocket代理等,會將實現過程分享出來,感興趣的可以一起造個輪子

專案地址

國內: https://gitee.com/tickbh/wmproxy

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

敏感的時間

  現實生活中大家都對時間有著概念,比如「快上班了,要不然要遲到了。」、「這班怎麼這麼久,怎麼還沒下班?」、「啊?已經晚上12點啦,等我這把遊戲玩完。」、「叮叮叮,起床鬧鐘一直在催著你起床了。」

  鬧鐘、自然變化、生物鐘為我們提供著時間的保證。而計算機的世界裡,就靠著硬體定時器,控制著時間的流逝,如果哪一天本地時間和別人的時間不一致了,此時需要找別人對時,這也就是經典的網路時間協定(NTP)

  現實的生活中,通常以分鐘或者小時乃至天去和別人約定時間,而在計算機的世界裡,在我們看來那就是朝生暮死的蚍蜉一般,他們生命較短,所以對他們來說,通常用s或者ms來乃至μs做通知,所以需要嚴格的遵守時間約定,絕對不允許有賴床的行為。

HTTP行為中的定時器

HTTP/HTTPS/WebSocket的存取撐起了網際網路總流量的半臂江山,日常接觸中APP或者遊戲這種對外服務的基本上均是通過HTTP及HTTPS進行服務,長連結由於相容小程式這類的大部分遊戲直接也從普通的Socket轉為WebSocket直接做全網的相容。WebSocket的基礎協定是由HTTP升級而來,所以也歸於HTTP協定。

主要有以下列行為定時器

  1. 連線超時定時器
  2. 讀操作超時定時器
  3. 寫操作超時定時器
  4. 讀/寫操作超時定時器(在規則的時間內同時完成讀和寫)
  5. keep-alive超時定時器(連線保持的最長時間)

定時器類的定義

相關的超時資料均存放在該類裡,接下來型別進行相應的處理

#[derive(Debug)]
pub struct TimeoutLayer {
    pub connect_timeout: Option<Duration>,
    pub read_timeout: Option<Duration>,
    pub write_timeout: Option<Duration>,
    pub timeout: Option<Duration>,
    /// keep alive 超時時長
    pub ka_timeout: Option<Duration>,

    read_timeout_sleep: Option<Pin<Box<Sleep>>>,
    write_timeout_sleep: Option<Pin<Box<Sleep>>>,
    timeout_sleep: Option<Pin<Box<Sleep>>>,
    ka_timeout_sleep: Option<Pin<Box<Sleep>>>,
}

連線超時定時器

  此時約定的是使用者端向伺服器端請求TCP連線建立的最長時間,如果沒有約定,將由系統連線超時的或者確認不可達的時候才返回失敗。

  如果沒有連線超時,那麼以下我們的型業務場景模擬:

  1. 在弱網的環境下,比如手機訊號不好的地方,一次連線請求建立可能會花費10秒左右才能返回,那麼如果我們沒有連線超時,使用者在等待了8秒,使用者端的介面都沒有辦法給出任何的響應,就會認為伺服器端出現了問題或者頻繁的重啟使用者端應用不斷的進行重試而得不到預期的反饋。在App設計中,對這種情況的使用者體驗極差,需要在指定的時間內給使用者反饋出當前網路無法存取。

  2. 使用者端的設計模型中,如果有定時請求或者埋點資料之類,如果沒有超時機制,容易出現短時間內開啟的socket過多出現資源耗盡的情況,其次使用者端不知道是否將資料已經進行傳送,不確認是否需要將該條資料進行快取以便下一次推播。

在設計模型中,連線超時必不可少,因為各種業務場景的不同,需要要不同的時間內得到預期的反饋。以下是連線超時在Rust中的實現,因為只存在於使用者端主動連線伺服器端,所以連線超時只在使用者端實現。

async fn inner_connect<A: ToSocketAddrs>(&self, addr: A) -> ProtResult<TcpStream> {
    if self.inner.timeout.is_some() {
        // 獲取是否設定了連線超時, 如果有連線超時那麼指定timeout
        if let Some(connect) = &self.inner.timeout.as_ref().unwrap().connect_timeout {
            match tokio::time::timeout(*connect, TcpStream::connect(addr)).await {
                Ok(v) => {
                    return Ok(v?)
                }
                Err(_) => return Err(ProtError::Extension("connect timeout")),
            }
        }
    }
    let tcp = TcpStream::connect(addr).await?;
    Ok(tcp)
}

通過指定超時時間來對連線的建立監聽。

讀操作超時定時器

大部分HTTP請求,只有得到完整的資料才能進行處理,少部分如檔案上傳這種可以邊上傳邊操作,而是否能開始操作關係到請求的響應時間。

讀超時大概有以下的可能:

  1. 伺服器處理請求的時間太長,導致使用者端等待超時。
  2. 伺服器返回的資料量過大,導致使用者端讀取資料的時間超過了規定的時間。
  3. 網路延遲或網路不穩定,導致使用者端無法在規定的時間內讀取完資料。

這造成使用者端無法及時處理資料,可以報錯好讓使用者端換備用線路或者備用伺服器等以便及時的處理資料。

讀操作我們不管伺服器端或者使用者端,不管http/1.1或者http/2均由is_read_end欄位來判定,在未讀完當前請求的資料前,判斷是否超時。

pub fn poll_ready(
    &mut self,
    cx: &mut Context<'_>,
    ready_time: Instant,
    is_read_end: bool,
    is_write_end: bool,
    is_idle: bool,
) -> ProtResult<()> {
    let now = Instant::now();
    if !is_read_end {
        if let Some(read) = &self.read_timeout {
            let next = ready_time + *read;
            if now >= next {
                return Err(crate::ProtError::Extension("read timeout"));
            }
            if self.read_timeout_sleep.is_some() {
                self.read_timeout_sleep.as_mut().unwrap().as_mut().set(tokio::time::sleep_until(next.into()));
            } else {
                self.read_timeout_sleep = Some(Box::pin(tokio::time::sleep_until(next.into())));
            }
            let _ = Pin::new(self.read_timeout_sleep.as_mut().unwrap()).poll(cx);
        }
    }
    
    Ok(())
}

其中比較複雜的是如何判斷is_read_end,因為需要區分伺服器端使用者端或者是http/1.1及http/2,這個原始碼實現 http2核心http1.1核心

寫操作超時定時器

對於使用者端的寫,就是將請求傳送到伺服器端,而伺服器端的寫,剛好是將返回傳送給使用者端。這是資料處理的重要的一環。

  在非同步的處理socket中,都會將socket設定成非阻塞,也就是nonblocking,此時我們會得到一個預設的核心緩衝區大小。如果在該緩衝區未滿前,我們寫入資料將是0等待,該緩衝區的資料將由系統進行資料傳送給另一端。

  如果對方不將我們的緩衝區資料讀走,那麼我們此時是無法在寫入到該緩衝區的,如果遠端端不讀,我們的傳輸速度將會無限的接近於0KB/S。

  此時如果是伺服器端,有數千上萬個這種該連線,每分鐘唯讀走幾個位元組的資料,那麼沒有寫入操作,我們將要保持上萬的空閒連線,而預設的埠連線數為65535,那麼使用者端將會耗盡我們的服務資源。此種為 [慢速攻擊]

該操作是通過is_write_end來判斷是否寫入完成。監聽方式和讀的一致,核心程式碼在 timeout,此處不再贅述

讀/寫操作超時定時器

該定時器是由讀和寫需要共同來完成的,在很多場景中,我們只關心該請求需要耗時多少時間來完成,此時我們不關心是讀的時間或者寫的時間,所以此時表示請求完成的需要在這個時間下完成

就比如HTTP/1.1中的Slow headers,也就是慢速頭攻擊。正常來說,我們的http/1.1的頭類似如下:

GET / HTTP/1.1\r\n
Host : wm-proxy.com\r\n
Connection: keep-alive\r\n
Keep-Alive: 900\r\n
Content-Length: 100000000\r\n
Content_Type: application/x-www-form-urlencoded\r\n
Accept: *.*\r\n
\r\n

整個報文的結束將有一個空白的\r\n,如果沒有收到該標記,伺服器端無法得到完整的頭資訊,也無法正確的把資料轉成Request,那麼此時使用者端可以佔用了大量的連線,從而使伺服器端拒絕服務。

此定時器判斷由is_read_endis_write_end有一方為false,監聽方法略。

keep-alive操作超時定時器

在http/1.1中,埠是可以複用的,從而減少socket的反覆建立關閉,並可以一定程度上快速的響應,這是埠請求完成後,又沒有後續的請求,此時當前socket線路為空閒,即當前空閒線路的保持時間。

  keep-alive為保持連線,此引數用的好可以極大的加速服務的存取,此引數設定不好的時候,如設定成9999s,那麼使用者端將會保持當前的空閒socket很久,從而另一個程度上造成了拒絕服務了。

  此判定由is_idle來判定,判斷當前是否空閒,也就是上一個請求已經讀寫均已完成,後一個請求還未進來,此時就為空閒時間。監聽方法雷同,略。

測試使用者端

let url = "http://www.baidu.com";
let req = Request::builder().method("GET").header(HeaderName::ACCEPT_ENCODING, "gzip").url(url).body("").unwrap(); Instant::now());
let client = Client::builder()
    // 是否支援HTTP2
    // .http2(false)
    // 是否僅使用HTTP2
    .http2_only(true)
    // 連線使時時間3秒
    .connect_timeout(Duration::new(5, 0))
    // 設定keep-alive時間10秒
    .ka_timeout(Duration::new(10, 0))
    // 設定讀超時5秒
    // .read_timeout(Duration::new(5, 0))
    // 設定寫超時5秒
    .write_timeout(Duration::new(5, 0))
    .connect(url).await.unwrap();
//發起請求
let (mut recv, sender) = client.send2(req.into_type()).await?;
//接收請求
let mut res = recv.recv().await.unwrap();
//接收所有body資料
res.body_mut().wait_all().await;

測試伺服器端

let mut server = Server::new(stream, Some(addr));
// 設定讀操作5秒
server.set_read_timeout(Some(Duration::new(5, 0)));
// 設定寫超時5秒
server.set_write_timeout(Some(Duration::new(5, 0)));
// 設定讀寫超時5秒
server.set_timeout(Some(Duration::new(5, 0)));
async fn operate(req: Request<RecvStream>) -> ProtResult<Response<String>> {
    let response = Response::builder()
        .version(req.version().clone())
        .body("Hello World\r\n".to_string())?;
    Ok(response)
}
let _ = server.incoming(operate).await;

結語

  時間的尺度越小,那麼對時間的敏感度越高,定時器是約束,也是保護,保護不受攻擊。感謝定時器給我們構建一個更加穩定的網際網路世界。

點選 [關注][在看][點贊] 是對作者最大的支援