NAT会话

nat network

我们知道传输层的任意一条流都是通过两个socket建立的,socket(ip,port,protocol)组成,因此一条流可以用五元组(srcIp,srcPort,dstIp,dstPort,protocol)表示。这个五元组中的任意一个元素都不能改变,否则就是另外一条流了。对于服务端来说,srcIpsrcPort中任意一个变了,就意味着一个新的接入连接;而对于客户端来说,dstIpdstPort中任意一个发生变化,访问的就是一个新的服务,比如通常80是一个http server服务,5432是一个postgresql数据库的服务端口。

NAT是用来做地址转换的,为了保证一条流五元组的一致性,我们需要保证NAT前和NAT后的IP、端口以及协议在一条流的整个生命周期都不变。为了保证这种不变性,我们需要维持一种NAT映射关系,我们把这个映射关系称之为NAT会话(session)。对于SNAT而言,transtitIp是被多个内网主机共享的,这个映射关系不能是永久的,否则就变成了内网(internalIp,internalPort)(transitIp,transitPort)1:1关系, (transitIp,transitPort)的组合关系很快便会出现耗尽的情况。因此对于SNAT而言,会话的另一个重要属性就是会话超时时间。而对于DNAT而言,由于内网是服务端,对外应该暴露固定的transitIptransitPort,且需要被映射到内网特定的某个主机提供的某个特定服务,即一组固定的internalIpinternalPort。这个映射关系是不随着外网的externalIpexternalPort改变而改变的,因此这种会话一般是永久性的。DNAT的会话是固定映射的,很好理解,因此下文我们讨论的会话主要是针对SNAT而言。

举个例子,假设有一条流:

image.png

NAT前:TCP 192.168.0.100:5000 -> 120.120.120.120:80
NAT后:TCP 110.110.110.110:6000 -> 120.120.120.120:80

那么TCP 192.168.0.100:5000 -> 120.120.120.120:80TCP 110.110.110.110:6000 -> 120.120.120.120:80的这样一个映射关系就可以称之为会话,而UDP 192.168.0.100:5000 -> 120.120.120.120:80UDP 110.110.110.110:6000 -> 120.120.120.120:80则是另一个会话。

1. 术语定义

为了方便下文阐述,我们先统一一下术语:

  • 定义一条流中从内网发往外网的流量为上行流量TCP 192.168.0.100:5000 -> 120.120.120.120:80就标记了一条流上行流量,我们记为:upKey=(internalIp,internalPort,externalIp,externalPort,protocol)
  • 定义一条流中从外网发往内网的流量为下行流量TCP 110.110.110.110:6000 -> 120.120.120.120:80就标记了一条流下行流量,我们记为:downKey=(externalIp,externalPort,transitIp,transitPort,protocol)

    注意这里我们并没有区分去程流量和回程流量,对于私网主动访问外网的场景,上行流量就是去程流量;对于外网主动访问内网的场景,上行流量就是回程流量。

此处我们可以导出会话session = upKey + downKey + expireTime

注意,这里我们讨论的都是对称型NAT,关于锥型NAT读者可以自行研究,相对来说会话信息会少一些,映射条件相对更宽松些。

2. 会话管理

一个会话的完整生命周期可以分为三个阶段:

  • 会话建立
  • 会话匹配(续约)
  • 会话老化

2.1. 会话匹配(续约)

对于一个上行报文,首先肯定是判断是否已经生成映射关系(session),如果有的话我们只需要按照这个会话进行源地址和源端口的替换就行了,否则才需要新建一个会话。因此在讲会话的建立之前,我们先来聊聊会话的匹配。上行报文可以用一个upSessKey来表示,我们需要通过这个upSessKey来找到对应的(transitIp,transitPort,protocol)的组合。很自然地,我们就想到用一个map结构来存储,即一个upSessKey(transitIp,transitPort,protocol)的映射关系,定义为upSessMap。同样对于下行报文,我们也可以使用一个downSessKey(internalIp,internalPort,protocol)的映射关系来表示,定义为downSessMap。因此会话的匹配,可以简单概括上行报文通过upSessKeyupSessMap中匹配获得session,下行报文通过downKeydownSessMap中匹配获得session

上文我们说过,会话是具有超时时间的,为了保证长连接不中断,NAT设备在接收到每一个业务报文完成NAT转换的同时,还需要对会话的超时时间进行刷新,以便在业务持续有报文的情况下会话不会老化,这个超时时间的刷新就可以称之为会话续约。

2.2. 会话建立

由于internalIpinternalPort是由内网主机决定的,externalIpexternalPort是由业务访问的目的外网主机决定的,而protocol是由通信双方的具体业务类型决定的(TCPUDPicmp),因此NAT设备实际上可以控制的只有transitIptranitPort,因此会话管理本质上管理的是transitIptranitPort的分配以及销毁。对于一个新建会话,为了保证和任何其他的流不冲突,最稳妥的方式就是使用一个新的(transitIp,transitPort)的组合,且不同的protocol之间的会话是不会冲突的。因此,我们可以定义如下会话分配池:

// NAT设备控制的一组用于NAT的外网IP
uint32_t transitIpPool[];
struct SessAllocateKey {
    uint32_t transitIp;
    uint8_t protocol;
}
struct BitMap ports;
// key: SessAllocateKey value: BitMap
struct HashMap *sessAllocateMap;

因此会话分配的逻辑就是通过一定的算法从transitIpPool中选出一个transitIp,然后从sessAllocateMap中获取当前protocol下此transitIp管理的端口分配情况的bitmap,选出一个未占用的port标记为占用,生成upSessKey -> (transtIp,transitPort,protocol)的映射关系。同时为了使下行流量(snat回程流量)能够顺利地找到会话,将下行流量的dstIpdstPort替换为原来的internalIpinternalPort,此时我们应该同时生成downKey -> (intenralIp,internalPort,protocol)的映射关系。为了保证会话在UDP通信结束或者TCP未正常断链的情况下能够被及时回收,此时我们还需要给会话额外添加一个超时时间。最终设计会话结构如下:

struct UpSesskey {
    uint32_t internalIp;
    uint16_t internalPort;
    uint32_t externalIp;
    uint32_t externalPort;
    uint8_t protocol;
}
struct DownSesskey {
    uint32_t transitIp;
    uint16_t transitPort;
    uint32_t externalIp;
    uint32_t externalPort;
    uint8_t protocol;  
}
struct Session {
   struct UpSesskey upSessKey;
   struct DownSessKey downSessKey;
   uint32_t expireTime;
}
// key: UpSessKey, value: Session
struct HashMap *upSessMap;
// key: DownSessKey, value: Session
struct HashMap *downSessMap;

2.2.1. TransitIp选择算法

  • Round Robin:不受报文内容的影响,各个transitIp上端口的分配比较平均
  • Hash:根据五元组进行HASH选择transitIp,对于特定的业务流场景,NAT后的IP比较稳定

2.2.2. 会话结构优化

由于upSessKeydownSessKey都包含了(externalIp,externalPort),因此如果在(externalIp,externalPort,protocol)组合不同的情况下,(internalIp,internalPort,protocol)(transitIp,transitPrt,protocol)的组合是可以一样的,这样也可以保证不同的upSessKey、不同的downSessKey之间都不冲突,也能定位到会话信息,完成NAT。同时(externalIp,externalPort,protocol)不同的情况下,对于NAT前或NAT后的流的五元组都不一样,保证无论是客户端还是服务端OS都能识别出来是不同的流。因此我们可以在(externalIp,externalPort,protocol)组合不同的情况下,复用(transitIp,transitPort,protocol)来提高NAT资源的使用率。因此我们的会话分配模型可以优化成如下结构:

uint32_t transitIpPool[];
// 将externalIp和externalPort加到会话分配的冲突域中
struct SessAllocateKey {
    uint32_t transitIp;
    uint32_t externalIp;
    uint16_t externalPort;
    uint8_t protocol;
}
struct BitMap ports;
// key: SessAllocateKey value: BitMap
struct HashMap *sessAllocateMap;

2.3. 会话老化

2.3.1. 超时老化

2.3.1.1. UDP

我们都知道UDP协议是面向无连接的,UDP会话并没有一个明确的“终结报文”来表示当前UDP会话可以被销毁了,因此UDP会话的唯一老化手段就是超时老化。UDP报文的超时老化时间并没有一个明确的标准,根据业务不同建议设置时间控制在30s-900s范围内。如果业务确实需要长时间保留UDP会话,建议客户端和服务端之间通过定时心跳进行保活。

2.3.1.2. TCP

对于TCP会话来说,相对就显得比较复杂了。TCP是面向连接的,有完整的TCP状态机,在TCP通信的任何阶段都有可能因为各种网络或主机异常问题导致TCP连接被意外释放,而这些意外释放往往不能被NAT设备感知,为了保证这种情况下会话能被释放,就需要各种超时老化机制来兜底。

2.3.1.2.1. sync超时

TCP通过三次握手创建一个连接,有以下两种情况可能会导致超时:

  • 对于client来说,在发送syn后进入syn_sent状态,等待server的syn+ack
  • 对于server来说,在发送syn+ack后进入syn_revd状态,等待client的ack
    在linux系统中,默认的syn超时是75秒,NAT设备的syn超时时间一般应该设置超过这个时间。
curl -o /dev/null -s -w "time_connect: %{time_connect}\ntime_starttransfer: %{time_starttransfer}\ntime_total: %{time_total}\n" 192.168.0.253:5000      
time_connect: 0.000000
time_starttransfer: 0.000000
time_total: 75.007882
2.3.1.2.2. ESTABLISHED超时

TCP经历过三次握手后,client和server都进入了established阶段。ESTABLISHED阶段的异常分为有数据传输和没有数据传输两种情况。

异常时有数据传输,为了保证TCP报文的可靠性,TCP提供了Tcp重传能力,默认情况会下会重传15次,最大重传时间120秒,重传15次后(总计约15分钟)后会断开连接,且此时是不会有FIN动作的,连接会直接关闭。因为一直存在重传报文,此情况下至少要保证NAT设备的超时重传时间大于单次最大重传时间——2分钟。

异常时如果没有数据传输,还需要关注TCP有没有开启Tcp KeepAlive|KeepAlive。开启KeepAlive功能后,最长可能会需要7875秒(约两个多小时)后才能断开。如果没有开启KeepAlive功能,则连接永远不会断开。此时NAT设备也需要设置一个合理的超时时间,保证NAT设备会话功能不受异常影响。

2.3.1.2.3. FIN超时

image.png

TCP通过四次挥手释放一个连接,有以下四种情况都可能会导致超时:

  1. 对于主动释放连接的一方来说
    • 在发送完FIN报文进入FIN_WAIT_1状态后,等待被动方的ACK报文
    • 在收到被动方的ACK报文进入FIN_WAIT_2后,等待被动方FIN报文
  2. 对于被动释放连接的一方来说
    • 在收到主动方的FIN报文并发送ACK进入CLOSE_WAIT状态后,此时被动方还有数据需要发送给主动方,等待主动方业务ACK报文。
    • 被动方发送FIN报文后,进入LAST_ACK状态,等待主动方ACK报文。

对于主动方来说,FIN_WAIT_1状态下会触发主动方的TCP重传(最大重传时间15分钟)后断开连接,而FIN_WAIT_2在linux下的默认超时时间为60秒。

$ more /proc/sys/net/ipv4/tcp_fin_timeout
60

对被动方来说,正常情况下,处于CLOSE_WAITLAST_ACK状态下会触发TCP重传(最大重传时间15分钟)后断开连接。某些情况下,如果被动方因为代码问题(IO阻塞之类的)未及时close socket,未能发出FIN报文,此时tcp如果设置了keepalive,会经历最长7875秒后,由内核断开连接。

2.3.2. TCP正常挥手

TCP通过四次挥手来完成连接的释放,因此我们可以维护TCP状态机,当收到第二个FIN报文后又收到ACK报文(即LAST_ACK),代表TCP挥手完成,此时只需要等待2MSL后即可释放会话(在被动方没有收到ACK报文的情况下,主动端可以有机会重发)。

2.3.3. TCP RST

RST用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。发送RST报文通常意味着发生了某些错误,接收端不必回ACK,因此RST报文也代表着会话的结束。NAT设备收到RST报文后也需要等待2MSL后释放会话(在RST未被对端收到的情况下,对端还有机会通过业务报文再次触发RST)。