您的位置:  首页 > 技术杂谈 > 正文

24. 从零用Rust编写正反向代理,细说HTTP行为中的几种定时器

2024-01-11 17:00 https://my.oschina.net/tickbh/blog/10771767 问蒙服务框架 次阅读 条评论

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,那么客户端将会耗尽我们的服务资源。此种为 <font color=green>[慢速攻击]</font>

该操作是通过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;

结语

  时间的尺度越小,那么对时间的敏感度越高,定时器是约束,也是保护,保护不受攻击。感谢定时器给我们构建一个更加稳定的互联网世界。

点击 <font color=green>[关注]</font><font color=green>[在看]</font><font color=green>[点赞]</font> 是对作者最大的支持

展开阅读全文
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接