mtu引发访问视频网站失败

协议栈

Posted by Secssion on December 26, 2023

现象

网关wan2 pppoe拨号上网,所有经网关wan2口上网的设备,访问抖音、斗鱼等视频网站均出现获取视频失败情况。

拓扑

tuopu_2023-12-27_22-46-12.png

分析

  1. 通过抓包可以看到,客户端与服务端的https握手未能成功,从服务端回复的一个包一直处于丢失状态。分析tcp流,ip头部已被设置上“禁止分片”标记。
  2. 从wan1口出发正常,那么对比从wan1口出去包的,发现从wan2口出去的tcp第一个包的mss值比正常大8字节,问题有可能出现协商处理的mss上。
  3. 尝试修改wan2的mtu为1000,终端访问视频网站依旧失败。
  4. 尝试修改终端的mtu为1000,终端访问视频网站正常。
  5. 目前可以肯定问题肯定出现在mtu上,但是为什么修改wan2的mtu问题依旧存在?因为mss协商只有tcp的两端有关。

mss协商

  1. MTU=MSS+IP头部长度+TCP头部长度, 协商的mss决定了TCP层数据最大长度
  2. 从tcp协议中可以看出来,协商的时候的mtu仅仅和tcp的两端存在关系,与中间经过的路由设备无关, 且发送方的mss仅用于通知接受方自己能接受的tcp层数据大小,且连接两端的mss相互独立。
    TCP provides an option that may be used at the time a connection is
    established (only) to indicate the maximum size TCP segment that can
    be accepted on that connection.  This Maximum Segment Size (MSS)
    announcement (often mistakenly called a negotiation) is sent from the
    data receiver to the data sender and says "I can accept TCP segments
    up to size X". The size (X) may be larger or smaller than the
    default.  The MSS can be used completely independently in each
    direction of data flow.  The result may be quite different maximum
    sizes in the two directions.
    
  3. 如果mss大小只与tcp的两端有关系的话,那路由设备使用需要额外占用8个字节PPPOE协议,且ip头部设置了”禁止分片”,这种情况下发送包长度到达mss下可能会一直丢包。

pmtu

  1. 路由转发设备上,当源主机设置IP_DF标记且包的大小大于接口的mtu时,给源主机放发送一个icmp包,让源主机调整mtu大小。
  2. 但是由于tcp代理与防火墙缘故,icmp包不一定能被源主机方收到。
  3. linux内核中发icmp包判断如下:
int ip_forward(struct sk_buff *skb)
{
...
	mtu = ip_dst_mtu_maybe_forward(&rt->dst, true);
    //数据帧长度超过mtu
	if (ip_exceeds_mtu(skb, mtu)) {
		IP_INC_STATS(net, IPSTATS_MIB_FRAGFAILS);
		icmp_send(skb, ICMP_DEST_UNREACH, ICMP_FRAG_NEEDED,
			  htonl(mtu));
		goto drop;
	}
...
}   

static bool ip_exceeds_mtu(const struct sk_buff *skb, unsigned int mtu)
{
	if (skb->len <= mtu)
		return false;
    //ip头部设置禁止分片分片
	if (unlikely((ip_hdr(skb)->frag_off & htons(IP_DF)) == 0))
		return false;

    //ip的分片长度大于mtu
	if (unlikely(IPCB(skb)->frag_max_size > mtu))
		return true;

	if (skb->ignore_df)
		return false;

	if (skb_is_gso(skb) && skb_gso_network_seglen(skb) <= mtu)
		return false;

    //数据帧的长度大于mtu
	return true;
}

解决方案

  1. 问题原因在于,网关使用PPPOE拨号上网,PPPOE协议会占用8个字节,而双方协商mss均为1460,从视频服务端回复的满载帧到PPPOE接收设备端的时候, 包比MTU大8字节会一直丢包。
  2. 网关设备识别出tcp三次握手的中的sync包,将sync包中的tcp mss值减去pppoe占用的8字节。
  3. 网上看到部分修改方案是在FORWARD链上修改mss, 这样的设备发本地出去的tcp包mss就不会被修改,对所有tcp修改mss应该在POSTROUTING链上修改。

方案1

  • 增加命令 iptables -t mangle -A POSTROUTING -p tcp --tcp-flags SYN,RST SYN -o wan2 -j TCPMSS --set-mss 1452, 包在路由转发前会tcp握手的mss值设置成1452

方案2

  • 在内核模块中,在FORWARD链注册新函数,通过ct的状态为UNTRACKED做预判并识别出tcp头部的syn标识,则将tcp的syn包并按实际调整mss大小

Linux内核mss协商过程

  • linux版本: 4.4.208

主动方发送syn

tcp_sync_2023-12-27_23-54-22.png

static void tcp_connect_init(struct sock *sk)
{
...
	tcp_sync_mss(sk, dst_mtu(dst));
	tcp_ca_dst_init(sk, dst);
	if (!tp->window_clamp)
		tp->window_clamp = dst_metric(dst, RTAX_WINDOW);
	tp->advmss = dst_metric_advmss(dst);
	if (tp->rx_opt.user_mss && tp->rx_opt.user_mss < tp->advmss)
		tp->advmss = tp->rx_opt.user_mss;
...   
}

advmss用于通知对端本端的mss,取dst_metric_advms与用户设置mss的最小值

static unsigned int tcp_syn_options(struct sock *sk, struct sk_buff *skb,
				struct tcp_out_options *opts,
				struct tcp_md5sig_key **md5)
{
...
	opts->mss = tcp_advertise_mss(sk);
...
}

将advmss写入到tcp option mss中,该值作为通知对端自身的mss

被动方接收syn&发送syn/ack报文

recv_syn_2023-12-28_00-02-23.png

int tcp_conn_request(struct request_sock_ops *rsk_ops,
		     const struct tcp_request_sock_ops *af_ops,
		     struct sock *sk, struct sk_buff *skb)
{
...
	tcp_clear_options(&tmp_opt);
	tmp_opt.mss_clamp = af_ops->mss_clamp;
	tmp_opt.user_mss  = tp->rx_opt.user_mss;
	tcp_parse_options(skb, &tmp_opt, 0, want_cookie ? NULL : &foc);  	
	if (want_cookie && !tmp_opt.saw_tstamp)
		tcp_clear_options(&tmp_opt);
	tmp_opt.tstamp_ok = tmp_opt.saw_tstamp;
	tcp_openreq_init(req, &tmp_opt, skb, sk);  
...
}

tcp_parse_options解析出tcp syn包的mss字段,保存到tmp_opt->mss_clamp

static void tcp_openreq_init(struct request_sock *req,
			     const struct tcp_options_received *rx_opt,
			     struct sk_buff *skb, const struct sock *sk)
{
...
	struct inet_request_sock *ireq = inet_rsk(req);
     //mss_clamp作为对端的mss保存到socket中 -- 被动方保存主动方的mss
	req->mss = rx_opt->mss_clamp; 
...
}

主动方接受syn/ack

static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb,
					 const struct tcphdr *th)
{
...
	struct inet_connection_sock *icsk = inet_csk(sk);
	struct tcp_sock *tp = tcp_sk(sk);
	struct tcp_fastopen_cookie foc = { .len = -1 };
	int saved_clamp = tp->rx_opt.mss_clamp;
	bool fastopen_fail;

    //从tcp option中解析出mss放在tp->rx_opt->mss_clamp -- 主动方保存被动方的mss
	tcp_parse_options(skb, &tp->rx_opt, 0, &foc) 
...    
}

被动方接受ack

struct sock *tcp_create_openreq_child(const struct sock *sk,
				      struct request_sock *req,
				      struct sk_buff *skb)
{
...
    //被动方接受syn包时,将对端的mss保存在request_sock中的mss中
    //被动方接受最后一个ack,取出request_socket中保存的mss放置到newtp中的mss_clamp
    //被动方建立的tcp套接字保存mss_clamp--对端的mss
    newtp->rx_opt.mss_clamp = req->mss;  
...
}				      
	

tcp发包使用mss

tcp_send_2023-12-28_00-07-05.png

unsigned int tcp_current_mss(struct sock *sk)
{
...
	const struct tcp_sock *tp = tcp_sk(sk);
	const struct dst_entry *dst = __sk_dst_get(sk);
	mss_now = tp->mss_cache;
	if (dst) {
		u32 mtu = dst_mtu(dst);
		if (mtu != inet_csk(sk)->icsk_pmtu_cookie)
			mss_now = tcp_sync_mss(sk, mtu);
	}
...
}

mss_cache保存真实有效的mss值,当pmtu有变更的时候,就需要重新调整mss

unsigned int tcp_sync_mss(struct sock *sk, u32 pmtu)
{
...
	struct tcp_sock *tp = tcp_sk(sk);
	struct inet_connection_sock *icsk = inet_csk(sk);
	int mss_now;
	mss_now = tcp_mtu_to_mss(sk, pmtu);
	tp->mss_cache = mss_now;
	return mss_now;
...
}

tcp_mtu_to_mss计算出的mss值重新设入mss_cache

static inline int __tcp_mtu_to_mss(struct sock *sk, int pmtu)
{
...
	const struct tcp_sock *tp = tcp_sk(sk);
	const struct inet_connection_sock *icsk = inet_csk(sk);
	int mss_now;
	mss_now = pmtu - icsk->icsk_af_ops->net_header_len - sizeof(struct tcphdr);
	if (mss_now > tp->rx_opt.mss_clamp)
		mss_now = tp->rx_opt.mss_clamp;
....
}

生成后的mss与协商的mss比较,取小值作为mss_now