NAT

NAT

即:网络地址装换。就是用来替换IP报文头部的地址信息。它通常部署在一个组织的网络出口的位置,通过将内部网络IP地址替换为出口的IP地址,由此提供公网的可达性和上层协议的连接能力。
RFC1918规定了三个保留地址段落:

10.0.0.0-10.255.255.255
172.16.0.0-172.31.255.255
192.168.0.0-192.168.255.255

这三个范围分别处于A,B,C类的地址段,不向特定的用户分配,被IANA(The Internet Assigned Numbers Authority,互联网数字分配机构)作为私有地址保留。这些地址可以在任何组织或企业内部使用,和其他Internet地址的区别就是,仅能在内部使用,不能作为全球路由地址。这就是说,出了组织的管理范围这些地址就不再有意义,无论是作为源地址,还是目的地址。
对于一个封闭的组织,如果其网络不连接到Internet,就可以使用这些地址而不用向IANA提出申请,而在内部的路由管理和报文传递方式与其他网络没有差异。
对于有Internet访问需求而内部又使用私有地址的网络,就要在组织的出口位置部署NAT网关,在报文离开私网进入Internet时,将源IP替换为公网地址,通常是出口设备的接口地址。一个对外的访问请求在到达目标以后,表现为由本组织出口设备发起,因此被请求的服务端可将响应由Internet发回出口网关。出口网关再将目的地址替换为私网的源主机地址,发回内部。这样一次由私网主机向公网服务端的请求和响应就在通信两端均无感知的情况下完成了。依据这种模型,数量庞大的内网主机就不再需要公有IP地址了。

NAT类型

全锥形NAT(Full Cone NAT)

其特点为:一旦内部主机端口对(iAddr:iPort)被NAT网关映射到(eAddr:ePort),所有后续的(iAddr:iPort)报文都会被转换为(eAddr:ePort);任何一个外部主机发送到(eAddr:ePort)的报文将会被转换后发到(iAddr:iPort)。
全锥形

限制锥形NAT(Port Restricted Cone NAT)

其特点为:一旦内部主机端口对(iAddr:iPort)被映射到(eAddr:ePort),所有后续的(iAddr:iPort)报文都会被转换为(eAddr:ePort);只有 (iAddr:iPort)向特定的外部主机hAddr发送过数据,主机hAddr从任意端口发送到(eAddr:ePort)的报文将会被转发到(iAddr:iPort)。
限制锥形

端口限制锥形NAT(Port Restricted Cone NAT)

其特点为:一旦内部主机端口对(iAddr:iPort)被映射到(eAddr:ePort),所有后续的(iAddr:iPort)报文都会被转换为(eAddr:ePort);只有(iAddr:iPort)向特定的外部主机端口对(hAddr:hPort)发送过数据,由 (hAddr:hPort)发送到(eAddr:ePort)的报文将会被转发到(iAddr:iPort)。
端口限制

对称型NAT(Symmetric NAT)

其特点为:NAT网关会把内部主机“地址端口对”和外部主机“地址端口对”完全相同的报文看作一个连接,在网关上创建一个公网“地址端口对”映射进行转换,只有收到报文的外部主机从对应的端口对发送回应的报文,才能被转换。即使内部主机使用之前用过的地址端口对去连接不同外部主机(或端口)时,NAT网关也会建立新的映射关系。
对称性
用Python可以检测目前你的NAT是什么类型。

实现思路:

Classic STUN (RFC3489)

The classic STUN datagram structure is as follow(TLV encoded):
All STUN messages consist of a 20 byte header:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|      STUN Message Type        |         Message Length        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
                         Transaction ID
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
                                                                |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

After the header are 0 or more attributes. Each attribute is TLV
encoded, with a 16 bit type, 16 bit length, and variable value:

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |         Type                  |            Length             |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                             Value                             ....
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Figure 2: Flow for type discovery process

                      +--------+
                      |  Test  |
                      |   I    |
                      +--------+
                           |
                           |
                           V
                          /\              /\
                       N /  \ Y          /  \ Y             +--------+
        UDP     <-------/Resp\--------->/ IP \------------->|  Test  |
        Blocked         \ ?  /          \Same/              |   II   |
                         \  /            \? /               +--------+
                          \/              \/                    |
                                           | N                  |
                                           |                    V
                                           V                    /\
                                       +--------+  Sym.      N /  \
                                       |  Test  |  UDP    <---/Resp\
                                       |   II   |  Firewall   \ ?  /
                                       +--------+              \  /
                                           |                    \/
                                           V                     |Y
                /\                         /\                    |
 Symmetric  N  /  \       +--------+   N  /  \                   V
    NAT  <--- / IP \<-----|  Test  |<--- /Resp\               Open
              \Same/      |   I    |     \ ?  /               Internet
               \? /       +--------+      \  /
                \/                         \/
                |                           |Y
                |                           |
                |                           V
                |                           Full
                |                           Cone
                V              /\
            +--------+        /  \ Y
            |  Test  |------>/Resp\---->Restricted
            |   III  |       \ ?  /
            +--------+        \  /
                               \/
                                |N
                                |       Port
                                +------>Restricted

test.py

#!/usr/bin/env python3
import io
import sys
import struct
import socket
import logging
from enum import Enum, unique
from random import randint

@unique
class MessageType(Enum):
    BINDING_REQUEST         = 0x0001
    BINDING_RESPONSE        = 0x0101
    BINDING_ERROR_RESPONSE  = 0x0111
    SHARED_SECRET_REQUEST   = 0x0002
    SHARED_SECRET_RESPONSE  = 0x0102
    SHARED_SECRET_ERROR     = 0x0112

@unique
class AttributeType(Enum):
    MAPPED_ADDRESS      = 0x0001
    RESPONSE_ADDRESS    = 0x0002
    CHANGE_REQUEST      = 0x0003
    SOURCE_ADDRESS      = 0x0004
    CHANGED_ADDRESS     = 0x0005
    USERNAME            = 0x0006
    PASSWORD            = 0x0007
    MESSAGE_INTEGRITY   = 0x0008
    ERROR_CODE          = 0x0009
    UNKNOWN_ATTRIBUTES  = 0x000a
    REFLECTED_FROM      = 0x000b
    XOR_MAPPED_ADDRESS  = 0x8020
    SERVER              = 0x8022
    SECONDARY_ADDRESS   = 0x8050

@unique
class NAT(Enum):
    PUBLIC      = 'The open Internet'
    UDP_BLOCKED = 'Firewall that blocks UDP'
    SYMMETRIC_UDP_FIREWALL = 'Firewall that allows UDP out, and responses have to come back to the source of the request'
    FULL_CONE = 'Full Cone NAT'
    SYMMETRIC = 'Symmetric NAT'
    PORT_RISTRICT  = 'Port Rristrict Cone NAT'
    ADDR_RISTRICT  = '(Address) Rristrict Cone NAT'

class StunHeader(object):
    """ 20 bytes header """
    def __init__(self, **kwargs):
        # 16 bits
        self.type = kwargs.pop('type', None)
        # 16 bits body length(excluding 20 bytes header)
        self.length = kwargs.pop('length', 0)
        # 128 bits
        self.transactionId = kwargs.pop('transactionId', randint(0, (1 << 128) - 1))
        if len(kwargs) != 0:
            raise ValueError('unknown kwargs: {}'.format(kwargs))
    def to_bytes(self):
        return struct.pack('!HH', self.type.value, self.length) + \
                self.transactionId.to_bytes(16, 'big')
    @classmethod
    def from_bytes(cls, data):
        assert len(data) == 20
        _type, _len, _tid = struct.unpack('!HH16s', data)
        return cls(
                type=MessageType(_type),
                length=_len,
                transactionId = int.from_bytes(_tid, 'big'))
    def __str__(self):
        return '<{}|{}|{:X}>'.format(
                self.type.name if self.type else None,
                self.length, self.transactionId)

class StunAttribute(object):
    HEADER_LENGTH = 4
    def __init__(self, **kwargs):
        self.type = kwargs.pop('type', None)
        self.length = kwargs.pop('length', 0)
        self.value = kwargs.pop('value', b'')
        if len(kwargs) != 0:
            raise ValueError('unknown param: {}'.format(kwargs))
    @classmethod
    def change_request(cls, change_addr=False, change_port=False):
        change_addr = '1' if change_addr else '0'
        change_port = '1' if change_addr else '0'
        # padding is unnecessary
        v = int('0' * 29 + change_addr + change_port + '0', 2)
        _binary = struct.pack('!I', v)
        return cls(type=AttributeType.CHANGE_REQUEST,
                length=len(_binary),
                value=_binary)

    def to_bytes(self):
        self.length = len(self.value)
        return struct.pack('!HH', self.type.value, self.length) + self.value
    def is_address(self):
        return self.length == 8 and self.type in [
                AttributeType.MAPPED_ADDRESS,
                AttributeType.RESPONSE_ADDRESS,
                AttributeType.CHANGED_ADDRESS]
    @property
    def address(self):
        if self.is_address():
            _, _family, port, ip = struct.unpack('!cBHI', self.value)
            return socket.inet_ntoa(struct.pack('!I', ip)), port
    def __str__(self):
        if self.is_address():
            return '<Attr {}|{}:{}>'.format(self.type.name,
                    self.address[0], self.address[1])
        else:
            return '<Attr {}>'.format(self.type.name if self.type else None)

class Message(object):
    def __init__(self, **kwargs):
        self.header = kwargs.pop('header', None)
        self.attributes = kwargs.pop('attributes', [])
    def to_bytes(self):
        # network order (big endian)
        _header = b''
        _body = b''
        for attr in self.attributes:
            _body += attr.to_bytes()
        self.header.length = len(_body)
        _header = self.header.to_bytes()
        return _header + _body
    @classmethod
    def from_bytes(cls, data):
        header = StunHeader.from_bytes(data[:20])
        attributes = []
        datalen = header.length
        f = io.BytesIO(data[20:])
        while datalen > 0:
            _type, _len = struct.unpack('!HH', f.read(StunAttribute.HEADER_LENGTH))
            _value = f.read(_len)
            attributes.append(StunAttribute(
                type=AttributeType(_type),
                length=_len,
                value=_value))
            datalen -= StunAttribute.HEADER_LENGTH + _len
        return cls(header=header, attributes=attributes)
    def __str__(self):
        return '{}: [{}]'.format(self.header,
                ','.join(map(str, self.attributes)))

def send_and_recv(sock, stun_server, request):
    logging.debug('SEND: {}'.format(request))
    sock.sendto(request.to_bytes(), stun_server)
    try:
        data, addr = sock.recvfrom(4096)
    except socket.timeout as e:
        logging.debug('RECV: timeout')
        return None
    response = Message.from_bytes(data)
    logging.debug('RECV: {}'.format(response))
    return response

def test_I(sock, stun_server):
    logging.info('running test I   with {}:{}'.format(stun_server[0], stun_server[1]))
    binding_request = Message(header=StunHeader(type=MessageType.BINDING_REQUEST))
    return send_and_recv(sock, stun_server, binding_request)

def test_II(sock, stun_server):
    logging.info('running test II  with {}:{}'.format(stun_server[0], stun_server[1]))
    binding_request = Message(header=StunHeader(type=MessageType.BINDING_REQUEST))
    change = StunAttribute.change_request(True, True)
    binding_request.attributes.append(change)
    return send_and_recv(sock, stun_server, binding_request)

def test_III(sock, stun_server):
    logging.info('running test III with {}:{}'.format(stun_server[0], stun_server[1]))
    binding_request = Message(header=StunHeader(type=MessageType.BINDING_REQUEST))
    change = StunAttribute.change_request(False, True)
    binding_request.attributes.append(change)
    return send_and_recv(sock, stun_server, binding_request)

def get_mapped_address(message):
    for attr in message.attributes:
        if attr.type is AttributeType.MAPPED_ADDRESS:
            return attr.address
def get_changed_address(message):
    for attr in message.attributes:
        if attr.type is AttributeType.CHANGED_ADDRESS:
            return attr.address
def test_nat(sock, stun_server, local_ip='0.0.0.0'):
    # Please refer to the README
    resp = test_I(sock, stun_server)
    if resp is None:
        return NAT.UDP_BLOCKED
    local_address = local_ip, sock.getsockname()[1]
    logging.info('local address is {}:{}'.format(local_address[0], local_address[1]))
    m1 = get_mapped_address(resp)
    changed_address = get_changed_address(resp)
    if m1 == local_address:
        # we can't tell whether it's public if we don't specify the local address
        resp = test_II(sock, stun_server)
        if resp is None:
            return NAT.SYMMETRIC_UDP_FIREWALL
        return NAT.PUBLIC
    logging.info('MAPPED_ADDRESS: {}:{}'.format(m1[0], m1[1]))
    resp = test_II(sock, stun_server)
    if not resp is None:
        return NAT.FULL_CONE
    resp = test_I(sock, changed_address)
    assert not (resp is None)
    m2 = get_mapped_address(resp)
    logging.info('MAPPED_ADDRESS: {}:{}'.format(m2[0], m2[1]))
    if m2 != m1:
        return NAT.SYMMETRIC
    resp = test_III(sock, stun_server)
    if resp is None:
        return NAT.PORT_RISTRICT
    else:
        return NAT.ADDR_RISTRICT

STUN_SERVERS = [
    ('stun.pppan.net', 3478),
    ('stun.ekiga.net', 3478),
    ('stun.ideasip.com', 3478),
    ('stun.voipbuster.com', 3478),
    ]

def main():
    if len(sys.argv) == 2:
        local_ip = sys.argv[1]
    else:
        local_ip = '0.0.0.0'
    logging.basicConfig(level=logging.INFO)
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.settimeout(3.0)
    # choose the fastest stun server to you
    ntype = test_nat(sock, STUN_SERVERS[0], local_ip)
    print('NAT_TYPE: ' + ntype.value)

if __name__ == '__main__':
    main()
test.py your-ip

参考代码

弊端

1)NAT使IP会话的保持时效变短。
因为一个会话建立后会在NAT设备上建立一个关联表,在会话静默的这段时间,NAT网关会进行老化操作。这是任何一个NAT网关必须做的事情,因为IP和端口资源有限,通信的需求无限,所以必须在会话结束后回收资源。通常TCP会话通过协商的方式主动关闭连接,NAT网关可以跟踪这些报文,但总是存在例外的情况,要依赖自己的定时器去回收资源。而基于UDP的通信协议很难确定何时通信结束,所以NAT网关主要依赖超时机制回收外部端口。通过定时器老化回收会带来一个问题,如果应用需要维持连接的时间大于NAT网关的设置,通信就会意外中断。因为网关回收相关转换表资源以后,新的数据到达时就找不到相关的转换信息,必须建立新的连接。当这个新数据是由公网侧向私网侧发送时,就会发生无法触发新连接建立,也不能通知到私网侧的主机去重建连接的情况。这时候通信就会中断,不能自动恢复。即使新数据是从私网侧发向公网侧,因为重建的会话表往往使用不同于之前的公网IP和端口地址,公网侧主机也无法对应到之前的通信上,导致用户可感知的连接中断。NAT网关要把回收空闲连接的时间设置到不发生持续的资源流失,又维持大部分连接不被意外中断,是一件比较有难度的事情。

2)破坏了IP端到端通信的能力
NAT通过修改IP首部的信息变换通信的地址。但是在这个转换过程中只能基于一个会话单位。当一个应用需要保持多个双向连接时,麻烦就很大。NAT不能理解多个会话之间的关联性,无法保证转换符合应用需要的规则。当NAT网关拥有多个公有IP地址时,一组关联会话可能被分配到不同的公网地址,这通常是服务器端无法接受的。更为严重的是,当公网侧的主机要主动向私网侧发送数据时,NAT网关没有转换这个连接需要的关联表,这个数据包无法到达私网侧的主机。这些反方向发送数据的连接总有应用协议的约定或在初始建立的会话中进行过协商。但是因为NAT工作在网络层和传输层,无法理解应用层协议的行为,对这些信息是无知的。NAT希望自己对通信双方是透明的,但是在这些情况下这是一种奢望。
3)妨碍安全协议工作
因为NAT篡改了IP地址、传输层端口号和校验和,这会导致认证协议彻底不能工作,因为认证目的就是要保证这些信息在传输过程中没有变化。对于一些隧道协议,NAT的存在也导致了额外的问题,因为隧道协议通常用外层地址标识隧道实体,穿过NAT的隧道会有IP复用关系,在另一端需要小心处理。ICMP是一种网络控制协议,它的工作原理也是在两个主机之间传递差错和控制消息,因为IP的对应关系被重新映射,ICMP也要进行复用和解复用处理,很多情况下因为ICMP报文载荷无法提供足够的信息,解复用会失败。IP分片机制是在信息源端或网络路径上,需要发送的IP报文尺寸大于路径实际能承载最大尺寸时,IP协议层会将一个报文分成多个片断发送,然后在接收端重组这些片断恢复原始报文。IP这样的分片机制会导致传输层的信息只包括在第一个分片中,NAT难以识别后续分片与关联表的对应关系,因此需要特殊处理。

NAT穿越

P2P

中继

这是最可靠但也是最低效的一种P2P通信实现。其原理是通过一个有公网IP的服务器中间人对两个内网客户端的通信数据进行中继和转发。如下图所示:
中继
客户端A和客户端B不直接通信,而是先都与服务端S建立链接,然后再通过S和对方建立的通路来中继传递的数据。这钟方法的缺陷很明显,当链接的客户端变多之后,会显著增加服务器的负担,完全没体现出P2P的优势。

逆向链接

在当两个端点中有一个不存在中间件的时候有效。例如,客户端A在NAT之后而客户端B拥有全局IP地址,如下图:

逆向链接

客户端A内网地址为10.0.0.1,且应用程序正在使用TCP端口1234。A和服务器S建立了一个链接,服务器的IP地址为18.181.0.31,监听1235端口。NAT A给客户端A分配了TCP端口62000,地址为NAT的公网IP地址155.99.25.11,作为客户端A对外当前会话的临时IP和端口。因此S认为客户端A就是155.99.25.11:62000。而B由于有公网地址,所以对S来说B就是138.76.29.7:1234。

当客户端B想要发起一个对客户端A的P2P链接时,要么链接A的外网地址155.99.25.11:62000,要么链接A的内网地址10.0.0.1:1234,然而两种方式链接都会失败。链接10.0.0.1:1234失败自不用说,为什么链接155.99.25.11:62000也会失败呢?来自B的TCP SYN握手请求到达NAT A的时候会被拒绝,因为对NAT A来说只有外出的链接才是允许的。

在直接链接A失败之后,B可以通过S向A中继一个链接请求,从而从A方向“逆向“地建立起A-B之间的点对点链接。很多当前的P2P系统都实现了这种技术,但其局限性也是很明显的,只有当其中一方有公网IP时链接才能建立。越来越多的情况下,通信的双方都在NAT之后,无法用到这类技术了。

UDP打洞

P2P打洞技术依赖于通常防火墙和cone NAT允许正当的P2P应用程序在中间件中打洞且与对方建立直接链接的特性。以下主要考虑两种常见的场景,以及应用程序如何设计去完美地处理这些情况。第一种场景代表了大多数情况,即两个需要直接链接的客户端处在两个不同的NAT之后;第二种场景是两个客户端在同一个NAT之后,但客户端自己并不需要知道。

端点在不同的NAT之下

假设客户端A和客户端B的地址都是内网地址,且在不同的NAT后面。A、B上运行的P2P应用程序和服务器S都使用了UDP端口1234,A和B分别初始化了与Server的UDP通信,地址映射如图所示:
不同NAT

现在假设客户端A打算与客户端B直接建立一个UDP通信会话。如果A直接给B的公网地址138.76.29.7:31000发送UDP数据,NAT B将很可能会无视进入的数据(除非是Full Cone NAT),因为源地址和端口与S不匹配,而最初只与S建立过会话。B往A直接发信息也类似。

假设A开始给B的公网地址发送UDP数据的同时,给服务器S发送一个中继请求,要求B开始给A的公网地址发送UDP信息。A往B的输出信息会导致NAT A打开一个A的内网地址与与B的外网地址之间的新通讯会话,B往A亦然。一旦新的UDP会话在两个方向都打开之后,客户端A和客户端B就能直接通讯,而无须再通过引导服务器S了。

UDP打洞技术有许多有用的性质。一旦一个的P2P链接建立,链接的双方都能反过来作为“引导服务器”来帮助其他中间件后的客户端进行打洞,极大减少了服务器的负载。应用程序不需要知道中间件具体是什么(如果有的话),因为以上的过程在没有中间件或者有多个中间件的情况下也一样能建立通信链路。

端点在相同的NAT之下

现在考虑这样一种情景,两个客户端A和B正好在同一个NAT之后(而且可能他们自己并不知道),因此在同一个内网网段之内。客户端A和服务器S建立了一个UDP会话,NAT为此分配了公网端口62000,B同样和S建立会话,分配到了端口62001,如下图:
相同NAT假设A和B使用了上节介绍的UDP打洞技术来建立P2P通路,那么会发生什么呢?首先A和B会得到由S观测到的对方的公网IP和端口号,然后给对方的地址发送信息。两个客户端只有在NAT允许内网主机对内网其他主机发起UDP会话的时候才能正常通信,我们把这种情况称之为"回环传输“(lookback translation),因为从内部到达NAT的数据会被“回送”到内网中而不是转发到外网。例如,当A发送一个UDP数据包给B的公网地址时,数据包最初有源IP地址和端口地址10.0.0.1:1234和目的地址155.99.25.11:62001,NAT收到包后,将其转换为源155.99.25.11:62000(A的公网地址)和目的10.1.1.3:1234,然后再转发给B。即便NAT支持回环传输,这种转换和转发在此情况下也是没必要的,且有可能会增加A与B的对话延时和加重NAT的负担。

对于这个问题,解决方案是很直观的。当A和B最初通过S交换地址信息时,他们应该包含自身的IP地址和端口号(从自己看),同时也包含从服务器看的自己的地址和端口号。然后客户端同时开始从对方已知的两个的地址中同时开始互相发送数据,并使用第一个成功通信的地址作为对方地址。如果两个客户端在同一个NAT后,发送到对方内网地址的数据最有可能先到达,从而可以建立一条不经过NAT的通信链路;如果两个客户端在不同的NAT之后,发送给对方内网地址的数据包根本就到达不了对方,但仍然可以通过公网地址来建立通路。值得一提的是,虽然这些数据包通过某种方式验证,但是在不同NAT的情况下完全有可能会导致A往B发送的信息发送到其他A内网网段中无关的结点上去的。

固定端口绑定

UDP打洞技术有一个主要的条件:只有当两个NAT都是Cone NAT(或者非NAT的防火墙)时才能工作。因为其维持了一个给定的(内网IP,内网UDP)二元组和(公网IP, 公网UDP)二元组固定的端口绑定,只要该UDP端口还在使用中,就不会变化。如果像对称NAT一样,给每个新会话分配一个新的公网端口,就会导致UDP应用程序无法使用跟外部端点已经打通了的通信链路。由于Cone NAT是当今最广泛使用的,尽管有一小部分的对称NAT是不支持打洞的,UDP打洞技术也还是被广泛采纳应用。

demo

代码开发中

webRTC

ICE

交互式连接设施Interactive Connectivity Establishment (ICE) 是一个允许你的浏览器和对端浏览器建立连接的协议框架。在实际的网络当中,有很多原因能导致简单的从A端到B端直连不能如愿完成。这需要绕过阻止建立连接的防火墙,给你的设备分配一个唯一可见的地址(通常情况下我们的大部分设备没有一个固定的公网地址),如果路由器不允许主机直连,还得通过一台服务器转发数据。ICE不是一种协议,而是一个框架(Framework),它整合了STUN和TURN。

模型如下:
在这里插入图片描述

  1. A收集所有的IP地址,并找出其中可以从STUN服务器和TURN服务器收到流量的地址;
  2. A向STUN服务器发送一份地址列表,然后按照排序的地址列表向B发送启动信息,目的是实现节点间的通信;
  3. B向启动信息中的每一个地址发送一条STUN请求;
  4. A将第一条接收到的STUN请求的回复信息发送给B;
  5. B接到STUN回复后,从中找出那些可在A和B之间实现通信的地址;
  6. 利用列表中的排序列最高的地址进一步的设备间通信。

由于该技术是建立在多种NAT穿透协议的基础之上,并且提供了一个统一的框架,所以ICE具备了所有这些技术的优点,同时还避免了任何单个协议可能存在的缺陷。因此,ICE可以实现在未知网络拓扑结构中实现的设备互连,而且不需要进行对手配置。另外,由于该技术不需要为VoIP流量手动打开防火墙,所以也不会产生潜在的安全隐患。

STUN

NAT的会话穿越功能Session Traversal Utilities for NAT (STUN) (缩略语的最后一个字母是NAT的首字母)是一个允许位于NAT后的客户端找出自己的公网地址,判断出路由器阻止直连的限制方法的协议。
客户端通过给公网的STUN服务器发送请求获得自己的公网地址信息,以及是否能够被(穿过路由器)访问。
STUN是一种Client/Server的协议,也是一种Request/Response的协议,默认端口号是3478。
STUN-RFC3489

stun
所有的STUN消息都包含20个字节的消息头,包括16位的消息类型,16位的消息长度和128位的事务ID。
stun head消息类型许可的值:

  • 0x0001:捆绑请求
  • 0x0101:捆绑响应
  • 0x0111:捆绑错误响应
  • 0x0002:共享私密请求
  • 0x0102:共享私密响应
  • 0x0112:共享私密错误响应

消息长度,是消息大小的字节数,但不包括20字节的头部。事务ID,128位的标识符,用于随机请求和响应,请求与其相应的所有响应具有相同的标识符。

消息属性:
消息头之后是0或多个属性,每个属性进行TLV编码,包括16位的属性类型、16位的属性长度和变长属性值。

value

属性类型定义:

  • MAPPED-ADDRESS:MAPPED-ADDRESS属性表示映射过的IP地址和端口。它包括8位的地址族,16位的端口号及长度固定的IP地址。
  • RESPONSE-ADDRESS:RESPONSE-ADDRESS属性表示响应的目的地址
  • CHASNGE-REQUEST:客户使用32位的CHANGE-REQUEST属性来请求服务器使用不同的地址或端口号来发送响应。
  • SOURCE-ADDRESS:SOURCE-ADDRESS属性出现在捆绑响应中,它表示服务器发送响应的源IP地址和端口。
  • CHANGED-ADDRESS:如果捆绑请求的CHANGE-REQUEST属性中的“改变IP”和“改变端口”标志设置了,则CHANGED-ADDRESS属性表示响应发出的IP地址和端口号。
  • USERNAME:USERNAME属性用于消息的完整性检查,用于消息完整性检查中标识共享私密。USERNAME通常出现在共享私密响应中,与PASSWORD一起。当使用消息完整性检查时,可有选择地出现在捆绑请求中。
  • PASSWORD:PASSWORD属性用在共享私密响应中,与USERNAME一起。PASSWORD的值是变长的,用作共享私密,它的长度必须是4字节的倍数,以保证属性与边界对齐。
  • MESSAGE-INTEGRITY:MESSAGE-INTEGRITY属性包含STUN消息的HMAC-SHA1,它可以出现在捆绑请求或捆绑响应中;MESSAGE-INTEGRITY属性必须是任何STUN消息的最后一个属性。它的内容决定了HMAC输入的Key值。
  • ERROR-CODE:ERROR-CODE属性出现在捆绑错误响应或共享私密错误响应中。它的响应号数值范围从100到699。
  • UNKNOWN-ATTRIBUTES:UNKNOWN-ATTRIBUTES属性只存在于其ERROR-CODE属性中的响应号为420的捆绑错误响应或共享私密错误响应中。
  • REFLECTED-FROM:REFLECTED-FROM属性只存在于其对应的捆绑请求包含RESPONSE-ADDRESS属性的捆绑响应中。属性包含请求发出的源IP地址,它的目的是提供跟踪能力,这样STUN就不能被用作DOS攻击的反射器。

具体的ERROR-CODE(响应号),与它们缺省的原因语句一起,目前定义:

  • 400(错误请求):请求变形了。客户在修改先前的尝试前不应该重试该请求。
  • 401(未授权):捆绑请求没有包含MESSAGE-INTERITY属性。
  • 420(未知属性):服务器不认识请求中的强制属性。
  • 430(过期资格):捆绑请求没有包含MESSAGE-INTEGRITY属性,但它使用过期的共享私密。客户应该获得新的共享私密并再次重试。
  • 431(完整性检查失败):捆绑请求包含MESSAGE-INTEGRITY属性,但HMAC验证失败。这可能是潜在攻击的表现,或者客户端实现错误
  • 432(丢失用户名):捆绑请求包含MESSAGE-INTEGRITY属性,但没有USERNAME属性。完整性检查中两项都必须存在。
  • 433(使用TLS):共享私密请求已经通过TLS(Transport Layer Security,即安全传输层协议)发送,但没有在TLS上收到。
  • 500(服务器错误):服务器遇到临时错误,客户应该再次尝试。
  • 600(全局失败):服务器拒绝完成请求,客户不应该重试。

属性空间分为可选部分与强制部分,值超过0x7fff的属性是可选的,即客户或服务器即使不认识该属性也能够处理该消息;值小于或等于0x7fff的属性是强制理解的,即除非理解该属性,否则客户或服务器就不能处理该消息。

实现原理

STUN原理
STUN协议的完整交互过程如上,下面我们来介绍具体实现步骤。

一般情况下,客户会配置STUN服务器提供者的域名,该域名被解析为IP地址和SRV过程的端口号。服务器名是“stun”,使用UDP协议发送捆绑请求,使用TCP协议发送共享私密请求。STUN协议的缺省端口号为3478。若要提供完整性检查,STUN在客户和服务器间使用128位的共享私密,作为在捆绑请求和捆绑响应中的密匙。

首先,客户通过发现过程获得它将与之建立TCP连接的IP地址和端口号。客户打开该地址和端口的连接,开始TLS协商,验证服务器的标识。客户发送共享私密请求。该请求没有属性,只有头。服务器生成响应。客户会在该连接上生成多个请求,但在获得用户名和密码后关闭该连接。

服务器收到共享私密请求,验证从TLS连接上到达的该请求;如果不是通过TLS收到的请求,则生成共享私密错误响应,并设置ERROR-CODE属性为响应号433;这里区分两种情况:若通过TCP收到请求,则错误响应通过收到请求的相同连接发送;若通过UDP收到请求,则错误响应发送回请求送出的源IP和端口。

服务器检查请求中的任何属性,当其中有不理解的小于或等于0x7fff的值,则生成共享私密错误响应,设置ERROR-CODE属性为响应号420,并包括UNKNOWN-ATTRIBUTE属性,列出它不理解的小于或等于0x7fff的属性的值。该错误响应通过TLS连接发送。

若请求正确,服务器创建共享私密响应,包含与请求中相同的事务ID,并包含USERNAME和PASSWORD属性。用户名在10分钟内有效。

共享私密响应通过与收到请求的相同的TLS连接发送,服务器保持连接打开状态,由客户关闭它。
接着,客户发送捆绑请求,携带的属性包括:

可选属性:RESPONSE-ADDRESS属性和CHANGE-REQUEST属性;
强制属性:MESSAGE-INTEGRITY属性和USERNAME属性。

客户发送捆绑请求,通过客户重传来提供可靠性。客户开始用100ms的间隔重传,每次重传间隔加倍,直至1.6秒。之间间隔1.6秒的重传继续,直到收到响应或总共已经发送了9次。因此,若9500ms后,还未收到响应,客户认为传输已经失败。

服务器检查捆绑请求的MESSAGE-INTEGRITY属性,不存在则生成捆绑错误响应,设置ERROR-CODE属性为响应号401;若存在,计算请求的HMACKey值。

服务器检查USERNAME属性,不存在则生成捆绑错误响应,设置ERROR-CODE属性为响应号432;若存在,但不认识该USERNAME的共享私密(例如,它超时了),生成捆绑错误响应,设置ERROR-CODE属性为响应号430。

若服务器知道该共享私密,但所计算的HMAC与请求的不同,生成捆绑错误响应,设置ERROR-CODE属性为响应号431。

假设消息完整性检查通过了,服务器检查请求中的任何属性的值,若遇到不理解的小于或等于0x7fff的值,生成捆绑错误响应,设置ERROR-CODE属性为响应号420,该响应包含UNKNOWN-ATTRIBUTE属性,并列出不理解的小于或等于0x7fff的属性。

若请求正确,服务器生成单个捆绑响应,包含与捆绑请求相同的事务ID。服务器在捆绑响应中加入MAPPED-ADDRESS属性,该属性的IP地址和端口号为捆绑请求的源IP地址和端口号。

捆绑响应的源地址和端口号取决于捆绑请求中CHANGE-REQUEST属性的值及捆绑请求收到的地址和端口号相关。
在这里插入图片描述
服务器在捆绑响应中加入SOURCE-ADDRESS属性,包含用于发送捆绑响应的源地址和端口号;加入CHANGED-ADDRESS属性,包含源IP地址和端口号。

如果捆绑请求中包含了USERNAME和MESSAGE-INTEGRITY属性,则服务器在捆绑响应中加入MESSAGE-INTEGRITY属性。

如果捆绑请求包含RESPONSE-ADDRESS属性,则服务器在捆绑响应中加入REFLECTED-FROM属性:如果捆绑请求使用从共享私密请求获得的用户名进行认证,则REFLECTED-FROM属性包含共享私密请求到达的源IP地址和端口号;若请求中的用户名不是使用共享私密分配的,则REFLECTED-FROM属性包含获得该用户名的实体的源IP地址和端口号;若请求中没有用户名,且服务器愿意处理该请求,则REFLECTED-FROM属性包含请求发出的源IP地址和端口号。

服务器不会重传响应,可靠性通过客户周期性地重发请求来保障,每个请求都会触发服务器进行响应。

客户端判断响应的类型是捆绑错误响应还是捆绑响应。捆绑错误响应通常在请求发送的源地址和端口收到;捆绑响应通常在请求中的RESPONSE-ADDRESS属性的地址和端口收到,若没有该属性,则捆绑响应将在请求发送的源地址和端口号收到。

  • 若是捆绑错误响应,客户检查响应中的ERROR-CODE属性的响应号:400至499之间的未知属性按属性400处理,500至599之间的未知属性按500处理,600至699之间的未知属性按600处理。任何100和399之间的响应都会使请求重传中止,但其他则忽略;若客户收到响应的属性类型大于0x7fff,则忽略该属性,若小于或等于0x7fff,则请求重传停止,并忽略整个响应;
    若是捆绑响应,客户检查响应的MESSAGE-INTEGRITY属性:如果不存在,客户在请求中加入MESSAGE-INTEGRITY属性,并放弃该响应;如果存在,客户计算响应的HMAC。如果计算出的HMAC与响应中的不同,则放弃该响应,并警告客户可能受到了攻击;若计算出的HMAC与响应中的匹配,则过程继续;
  • 不论收到捆绑响应还是捆绑错误响应,都将中止该请求的重传。客户在第一次响应后继续监听捆绑请求的响应10秒钟,如果这期间它收到任何消息类型不同的响应或不同的MAPPED-ADDRESS属性,它将警告用户可能受到攻击;并且,如果客户收到的捆绑响应次数超过它发送的捆绑请求数的两倍,它将警告用户可能受到攻击;若捆绑响应经过认证,上述攻击并未导致客户丢弃MAPPED-ADDRESS,则客户可以使用该MAPPED-ADDRESS和SOURCE-ADDRESS属性。

NAT

网络地址转换协议Network Address Translation (NAT) 用来给你的(私网)设备映射一个公网的IP地址的协议。一般情况下,路由器的WAN口有一个公网IP,所有连接这个路由器LAN口的设备会分配一个私有网段的IP地址(例如192.168.1.3)。私网设备的IP被映射成路由器的公网IP和唯一的端口,通过这种方式不需要为每一个私网设备分配不同的公网IP,但是依然能被外网设备发现。
一些路由器严格地限定了部分私网设备的对外连接。这种情况下,即使STUN服务器识别了该私网设备的公网IP和端口的映射,依然无法和这个私网设备建立连接。这种情况下就需要转向TURN协议。

TURN

一些路由器使用一种“对称型NAT”的NAT模型。这意味着路由器只接受和对端先前建立的连接(就是下一次请求建立新的连接映射)。
NAT的中继穿越方式Traversal Using Relays around NAT (TURN) 通过TURN服务器中继所有数据的方式来绕过“对称型NAT”。你需要在TURN服务器上创建一个连接,然后告诉所有对端设备发包到服务器上,TURN服务器再把包转发给你。很显然这种方式是开销很大的,所以只有在没得选择的情况下采用。

turn

相关协议:
RFC3489中定义的STUN,即简单地用UDP穿过NAT(STUN)是个轻量级的协议。它允许应用发现它们与公共互联网之间存在的NAT和防火墙及其他类型。它还为应用提供判断NAT给它们分配的公共网际协议(IP)地址。STUN可工作在许多现存NAT上,并且不需要它们做任何特别的行为。它允许广泛的各类的应用穿越现存的NAT设施。

RFC5389中对STUN协议进行了修订,将其定位于为穿透NAT提供工具,即NAT会话穿透效用是一个用于其他解决NAT穿透问题协议的协议。它可以用于终端设备检查由NAT分配给终端的IP地址和端口号。同时,它也被用来检查两个终端之间的连接性,好比是一种维持NAT绑定表项的保活协议。STUN本身并不是一种完整的NAT穿透解决方案。它相当于是一种NAT穿透解决方案中的工具。这是与先前的版本相比最重要的改变。之前的RFC3489中定义的STUN是一个完整的穿透NAT解决方案。此外,最大的区别是支持TCP穿透。

RFC5766中对STUN协议再次进行了扩展,即中继穿透NAT:STUN的扩展。TURN与STUN的共同点都是通过修改应用层中的私网地址达到NAT穿透的效用,异同点是TUN采用了两方通讯的“中间人”方式实现穿透,突破了原先STUN协议无法在两台主机不能够点对点直接连接下提供作用的限制。

RCF3489
RCF5389
RCF5766

引用文章
P2P技术之STUN、TURN、ICE详解

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值