【Linux网络系列】从底层寻址到应用穿透:深度剖析 ARP、ICMP 协议与 FRP 内网穿透、内网打洞、代理机制
🔥 本文专栏:Linux网络
🌸作者主页:努力努力再努力wz
💪 今日博客励志语录:我们无法预知每一个选择的对错,但我们可以赋予每一个选择正确的意义。引入
那么在之前的内容中,我已经介绍了应用层协议、传输层协议以及网络层协议。而这篇博客将正式进入 TCP/IP 体系结构中的最后一层——数据链路层协议。
我们知道,当一个 IP 数据包从当前主机发送出去之后,接下来便会进入外部网络,也就是运营商所构成的路由器网络。随后,该数据包会在运营商的多个路由器之间被逐跳转发,最终到达目标主机。
对于网络中间的路由器节点来说,当其接收到一个数据帧时,首先会对数据帧的帧头和帧尾进行校验(例如通过 FCS 等机制验证帧的完整性)。如果校验没有问题,随后才会将其中的 IP 数据包递交给网络层协议栈进行处理。具体来说,就是从 IP 报文中提取目标 IP 地址,并根据该地址查询本地的路由表,从而确定下一跳设备的 IP 地址以及对应的出口接口。
在获取到下一跳设备的 IP 地址以及出口接口之后,路由器会将该 IP 数据包重新封装为一个新的数据帧,并从对应的出口接口发送给下一跳设备。由于需要重新封装成数据帧,因此必须为该 IP 数据包重新添加新的帧头和帧尾。
在以太网帧头中包含两个非常关键的字段:
- 源 MAC 地址(Source MAC Address)
- 目标 MAC 地址(Destination MAC Address)
其中,源 MAC 地址就是当前路由器出口接口的 MAC 地址;而目标 MAC 地址则是下一跳设备接口的 MAC 地址。
这里还需要稍微补充一下当前设备是如何管理自身网络接口的。对于 Linux 操作系统而言,内核会负责管理底层硬件设备,其中就包括网卡。Linux 内核管理设备的一种基本方式可以概括为 “先描述,再组织”:首先使用结构体对设备进行抽象描述,然后再通过各种数据结构将这些描述对象组织起来。
网卡通常由 struct net_device 结构体进行描述,该结构体记录了网卡的大量属性信息,例如设备名称、MTU、操作函数集合等。struct net_device 中还包含一个指针,指向 in_device 结构体。in_device 结构体中维护了与该网卡相关的 IP 地址信息,其中的一个字段指向一个 IP 地址链表的头节点。
该 IP 链表中的每一个节点都表示该网卡绑定的一个 IP 地址,以及与之对应的直连网段信息(例如子网掩码等)。除了维护这些 IP 信息之外,struct net_device结构体中还会保存该网卡自身的 MAC 地址。
因此,对于当前主机而言,它必然能够获取自己出口接口的 MAC 地址。但是,对于下一跳设备入口接口的 MAC 地址,当前设备在初始情况下是无法直接得知的。那么当前设备是如何获取这个 MAC 地址的呢?这就涉及到接下来要介绍的 ARP(Address Resolution Protocol,地址解析协议)。
ARP协议
根据上文,我们知道,一个 IP 数据包在发送到相邻设备(也就是下一跳设备)之前,需要被封装成数据帧重新发出。因此,设备首先需要获取下一跳设备入口网络接口的 MAC 地址。然而,当前设备只知道下一跳设备接口的 IP 地址,因此需要通过发送 ARP 请求 来获取下一跳设备接口对应的 MAC 地址。
那么在讲解 ARP 协议之前,首先需要认识一下 数据帧的结构。对于 以太网帧 来说,其整体结构主要包含 帧头(Header) 和 帧尾(Trailer) 两部分,而在帧头与帧尾之间的部分则是 有效载荷(Payload)。该有效载荷中通常封装的是 IP 报头、传输层报头以及应用层数据。
而对于 帧尾 来说,其主要是一个 校验字段(Frame Check Sequence,FCS),用于检验数据帧在物理传输过程中是否出现差错。该校验通常由 网卡(NIC)硬件 完成。如果检测到校验错误,网卡会直接丢弃该数据帧,而不会通过 DMA 将其拷贝到主机内存中,因此操作系统内核也就无法接收到该帧,自然也不会将其交付给上层的 网络层协议栈 进行处理。
而对于 帧头 来说,其中包含两个非常核心的字段:源 MAC 地址 和 目标 MAC 地址。
其中:
- 源 MAC 地址 表示该数据帧是从相邻设备的哪个网络接口发送出来的;
- 目标 MAC 地址 则表示该数据帧是发送给目标设备的哪个网络接口。
因此,对于网卡来说,除了会校验帧尾的 FCS 校验值 之外,还会检查帧头中的 目标 MAC 地址。如果该地址既 不等于网卡自身的 MAC 地址,也 不属于该网卡所订阅的组播 MAC 地址,同时 也不是广播 MAC 地址(FF:FF:FF:FF:FF:FF),那么网卡同样会直接丢弃该数据帧。
此外,帧头中还有一个 Type 字段(EtherType),该字段用于标识帧中封装的 上层协议类型。例如:
0x0800表示 IPv4 协议0x0806表示 ARP 协议
操作系统在接收到数据帧后,会根据这 2 字节的 Type 字段,将数据帧分发给对应的 协议处理模块,从而进入相应的协议栈进行进一步处理。
当设备需要将数据帧转发到 下一跳设备 时,虽然已经知道该设备某个网络接口的 IP 地址,但并不知道其 MAC 地址。如果当前本地的 ARP 缓存(ARP Cache) 没有命中,那么设备就需要发送 ARP 请求报文 来进行地址解析。
需要注意的是,ARP 协议属于数据链路层协议。既然其工作在数据链路层,那么 ARP 报文在封装时 不会包含 IP 报头以及传输层报头。也就是说,一个 ARP 数据包的结构就是:
- 以太网 帧头
- ARP 报文载荷
- 帧尾(FCS)
其中,中间部分就是 ARP 协议的有效载荷。
ARP 请求报文的含义可以简单理解为:
“谁拥有这个 IP 地址?请把你的 MAC 地址告诉我。”
但是这里会出现一个问题:ARP 报文本身仍然需要封装在 以太网帧 中,而帧头中必须填写 目标 MAC 地址。但此时发送方并不知道目标 MAC 地址,因此只能将 目标 MAC 地址设置为广播地址FF:FF:FF:FF:FF:FF,然后从对应的网络接口发送出去。
假设该网络接口是 以太网接口,那么接口的另一端可能连接的是 路由器,也可能连接的是 交换机。如果连接的是交换机,那么交换机的另一侧往往会连接 多台设备(如路由器或主机)。
当路由器发送一个 广播 ARP 请求 到交换机时,交换机会按照 二层交换机制 进行转发。交换机内部维护着一张 MAC 地址表(MAC Address Table),正常情况下会根据 目标 MAC 地址 查表决定转发端口。但由于此时目标 MAC 地址是 广播地址,交换机不会进行单端口转发,而是执行 广播泛洪(Flooding):
即将该 ARP 请求帧 发送到除入口端口之外的所有端口。
于是,连接在交换机上的其他设备都会收到该 ARP 广播帧。由于帧头中的 目标 MAC 地址是广播地址,因此网卡不会丢弃该帧,而是会将其从 网卡缓冲区 通过 DMA 拷贝到主机内存中的 环形缓冲区(Ring Buffer) 中,然后触发 硬件中断,通知 CPU 处理该数据帧。
CPU 随后进入 内核态,由操作系统内核读取该数据帧。
内核在读取数据帧之后,首先会解析 帧头中的 Type 字段,从而确定该数据帧封装的是 ARP 协议报文。随后,该帧会被交付给 内核中的 ARP 协议模块 进行处理。
接下来,内核会继续解析 ARP 报文的有效载荷部分。
在 ARP 报文中,首先是 操作码(Opcode)字段,用于标识当前报文的类型:
1表示 ARP 请求(Request)2表示 ARP 响应(Reply)
随后是 硬件类型(Hardware Type) 和 协议类型(Protocol Type) 字段:
- 硬件类型 用于标识底层链路类型,例如在以太网环境中,其值为 1;
- 协议类型 用于标识要解析的网络层协议,例如 IPv4。
接下来是:
- 硬件地址长度(Hardware Address Length)
- 协议地址长度(Protocol Address Length)
例如:
- 如果链路层是 以太网,则 MAC 地址长度为 6 字节;
- 如果网络层协议是 IPv4,则 IP 地址长度为 4 字节。
而在这些字段之后,就是 ARP 报文最核心的四个字段:
- 发送端 MAC 地址(Sender MAC Address)
- 发送端 IP 地址(Sender IP Address)
- 目标 MAC 地址(Target MAC Address)
- 目标 IP 地址(Target IP Address)
如果当前是 ARP 请求报文,那么:
- 目标 MAC 地址字段通常为空或填充为 0
- 发送端 MAC 地址、发送端 IP 地址以及目标 IP 地址都是有效的
对于操作系统内核来说,其处理流程通常如下:
- 检查操作码字段,判断当前报文是 ARP 请求 还是 ARP 响应;
- 如果是 ARP 请求,则继续检查 目标 IP 地址;
- 判断该 IP 地址是否是 本机某个网络接口所绑定的 IP 地址。
如果 不匹配,则说明该 ARP请求 并不是发给本机的,此时内核会 直接丢弃该报文,不做任何响应。
如果 匹配,说明当前设备正是该 IP 地址的拥有者,那么内核就会:
- 查询绑定该 IP 地址的 网络接口对应的 MAC 地址;
- 构造 ARP 响应报文。
在 ARP 响应报文中:
- Opcode = 2,表示 ARP 响应;
- 发送端 IP 地址 为被查询的接口 IP;
- 发送端 MAC 地址 为该接口对应的 MAC 地址;
- 目标 IP 地址 和 目标 MAC 地址 则分别填写为 ARP 请求发送方的 IP 和 MAC 地址。
最后,该 ARP 响应会以 单播帧 的形式发送给请求方。
当请求方收到 ARP响应 后,就会将 IP 地址与 MAC 地址的映射关系 记录到本地的 ARP 缓存(ARP Cache) 中。之后在向该 IP 地址发送数据时,就可以直接使用缓存中的 MAC 地址 封装数据帧,并将其转发给 下一跳设备。
ARP表以及ARP邻居状态更新
那么认识了当前设备如何获取下一跳设备接口的 MAC地址的流程之后,根据上文我们已经说明:IP 数据包从当前设备发出时,需要重新封装成数据帧。在这个重新封装的过程中,帧头中的 源 MAC 地址 为当前设备出口网络接口的 MAC 地址,而 目标 MAC 地址 则是下一跳设备入口接口的 MAC 地址。
在发送 ARP 广播请求之前,设备首先会查询本地的 ARP 缓存。如果缓存命中,则可以直接获得目标 MAC 地址;如果缓存未命中,才会发送一个广播的 ARP 请求报文。那么这个 ARP 缓存究竟是什么呢?
ARP 缓存可以简单理解为由一个个条目组成的集合,每一个条目都是一个 键值对:键为邻居网络接口的 IP 地址,值为对应的 MAC 地址。
在实际实现中,ARP 缓存通常采用 哈希表 来实现。该哈希表本质上是一个指针数组,每个数组元素指向一个链表。当发生哈希冲突时,多个条目会被组织到同一个链表中。链表中每一个节点的键为 IP 地址,而值为对应的 MAC 地址。当然,在真实的实现中,节点中不仅包含这两个字段,还包含其他辅助字段,用于维护条目的状态和生命周期等信息。
因此,在将 IP 数据包重新封装成帧之前,内核首先会查询本地 ARP 缓存。查询过程大致如下:
首先,将 下一跳设备的 IP 地址 与一个随机数进行运算,然后对结果进行哈希处理,再通过取模映射到哈希数组的有效索引范围内,从而得到对应的 哈希索引。接着遍历该索引对应链表中的所有节点:
- 如果命中,则得到对应的 MAC 地址,并将 IP 数据报封装为以太网帧后发送;
- 如果未命中,则会发送 ARP 广播请求报文 来获取对应的 MAC 地址。
接下来我们看 ARP 响应到达时的处理流程。
当设备(例如路由器)收到对方发送的 ARP 响应报文时,整个流程首先从网卡开始。网卡会将接收到的物理信号转换为数字信号,并存储到网卡缓冲区中。随后对帧头与帧尾进行校验,如果校验通过,则进入后续处理流程。
在现代网络设备中,通常会使用 多队列机制。每个 CPU 核心通常会对应一个接收队列。网卡需要根据数据包的特征决定将该数据帧分配到哪个队列。
对于常见的 IP 数据包,网卡通常可以提取 五元组(源 IP、目的 IP、源端口、目的端口、协议号)进行哈希,从而实现流量在多个队列之间的均衡分布。
但是 ARP 报文并不包含 IP 头部和传输层头部,因此无法提取五元组。类似的情况还包括某些直接运行在 IP 层之上的协议,例如 OSPF 报文,它们没有传输层头部,因此也无法提取端口号构成完整五元组。在这种情况下,网卡通常会退而求其次,例如提取 三元组(源 IP、目的 IP、协议号) 进行哈希。
而对于 ARP 报文,由于其甚至不包含 IP 头部,因此只能进一步退化为提取 源 MAC 地址、目的 MAC 地址以及协议类型字段 进行哈希计算,从而得到索引,并查询间接表以确定队列号。在某些实现中,也可能将这些数据帧统一放入 0 号接收环形缓冲区 中。
接下来,网卡根据得到的队列号查询对应的寄存器组,计算出需要写入的 内存分片起始地址。随后通过 DMA(Direct Memory Access) 将数据从网卡缓冲区拷贝到主内存中。拷贝完成后触发 硬件中断,CPU 响应中断并进入内核态,开始处理该数据帧。
内核读取数据帧之后,会根据 以太网帧头中的协议字段(EtherType) 判断上层协议类型。当协议类型为 ARP 时,该帧会被交给 ARP 协议处理模块 进行处理。随后解析 ARP 的有效载荷部分,识别其中的 操作码(Opcode),如果发现是 ARP 响应报文,则继续提取发送端的 IP 地址 以及其接口对应的 MAC 地址,并将这组映射关系插入到 ARP 缓存中。
插入过程同样需要进行哈希计算:
内核会将发送端的 IP 地址与一个随机数进行运算,这样做的目的是提高哈希分布的均匀性。随后对结果进行取模运算,将其映射到哈希数组的有效索引范围内,从而得到哈希索引。接着遍历该索引对应链表中的所有节点:
- 如果命中,则更新该 IP 地址对应条目的 MAC 地址;
- 如果未命中,则创建新的节点并插入链表。
例如在 Linux 内核中,ARP 条目的核心结构类似如下:
structneighbour{structneighbour __rcu *next;// 链表指针,指向下一个哈希冲突节点structneigh_table*tbl;// 所属邻居表(arp_tbl)void*primary_key;// 键:IP 地址unsignedchar ha[MAX_ADDR_LEN];/* 值:对应的硬件地址(MAC)这里 MAC 地址的长度为 48 位,也就是 6 个字节。因此,在结构体中通常使用一个 char 类型的数组来存储 MAC 地址。该数组的长度为 6,每个数组元素占用 1 个字节,分别对应 MAC 地址中的一个字节字段。 换句话说,整个数组顺序存储了 MAC 地址的 6 个字节,每一个数组元素都表示 MAC 地址中的 一个字节片段,从而完整表示一个 48 位的 MAC 地址。*/unsignedlong used;// 最近一次被使用的时间unsignedlong confirmed;// 最近一次确认可达的时间unsignedlong updated;// 最近一次状态更新的时间 __u8 nud_state;// 邻居状态机:REACHABLE、STALE、DELAY 等structsk_buff_head arp_queue;// 等待 MAC 解析的数据包队列:ARP 缓存未命中(状态为 NONE 或 INCOMPLETE),这个 skb 就不会被发出去,而是会被挂载到 neighbour->arp_queue 这个链表上。等 ARP 响应回来后,内核再顺着这个队列把包发出去。structtimer_list timer;// 定时器:负责处理超时与状态转换};需要注意的是,每一个 ARP条目 除了 IP 与 MAC 的映射关系之外,还包含一个非常重要的字段,即 邻居状态(nud_state)。之所以需要维护状态,是因为 ARP 条目具有时效性。
例如,当某个路由器下线或者链路断开时,对应接口的 IP 地址与 MAC 地址映射关系就会失效。因此 ARP 缓存中的条目不可能永久存在,而是需要根据一定的策略进行更新与淘汰。
例如,当发送方收到一个 ARP响应 ,并且在 ARP缓存 中未命中时,说明该 IP 与 MAC 映射关系是新学习到的。此时会创建新的 ARP条目 并插入链表,同时将其状态设置为 REACHABLE。
通常每个条目都会设置一个 可达时间 (例如约 30 秒)。如果在这段时间内没有再次确认该邻居的可达性,那么条目的状态就会被设置为 STALE,表示该条目已经过期。
需要注意的是:
STALE 状态仅仅表示该条目已经过期,但并不会立即删除该条目。
接下来结合 ARP 状态,我们再将视角切换回 发送方。
根据上文,当发送方需要将 IP 数据包封装成帧并发送时,首先会查询 ARP 缓存。查询过程同样是:将 下一跳 IP 地址 与随机数进行运算,计算哈希值并映射到哈希数组索引,然后遍历对应链表:
- 如果未命中,则发送 ARP 广播请求;
- 如果命中,则继续检查该 ARP 条目的状态。
如果状态为 REACHABLE,说明该条目仍然有效,对方接口是可达的,此时内核会直接将 IP 数据报封装为以太网帧并发送给下一跳设备。
如果条目的状态为 STALE,说明该条目已经过期。直觉上可能会认为:既然条目已经过期,就应该先发送 ARP 请求重新验证,然后再发送数据。但实际上 内核并不会这样处理,而是仍然会信任该条目,并直接发送数据帧。
之所以这样设计,是因为在实际网络环境中,数据包数量非常庞大。如果每当 ARP 条目过期时都先发送 ARP 广播来验证,那么整个网络中将会充斥大量 ARP 广播报文,这不仅会增加网络负载,还可能导致广播风暴。因此,内核只有在 ARP 缓存完全没有对应条目时,才会发送 ARP 广播请求。
不过需要注意的是,如果链路实际上已经断开,那么此时根据 STALE 条目发送的数据帧就可能无法到达目标设备。
因此,当内核发现 ARP 条目为 STALE ,并准备发送数据包时,会进行一些额外处理。例如,发送的数据包对应一个 skb(socket buffer),在填充以太网帧头时需要访问 ARP 条目, skb会持有一个指向 neighbour 结构的指针。
这里有一个细节值得展开说明。你可能会有疑问:为什么 skb 需要持有一个指向 neighbour 结构体的指针,而不是每次发送时都重新查询 ARP 哈希表?是因为当应用层调用 send() 或 write() 写入较大的数据时,这些数据首先会被追加到 发送缓冲区中。具体来说,数据会被追加到 当前发送队列中最后一个 skb 的分片数组(fragment array)中最后一个条目所引用的内存页的尾部。
如果该 skb 的分片数组已经达到上限,无法再容纳新的分片描述符,那么内核就需要 分配新的 skb 结构 来继续承载后续数据。
需要注意的是,这些分片数组中的数据页通常 共享同一个 skb 的线性区(linear area)所对应的协议上下文。换句话说,线性区主要用于存储各层协议头部,而真正的大块应用层数据通常存储在 页分片(page fragments) 中。
当数据准备发送时,如果某个内存页中存储的数据量 超过了 MSS(Maximum Segment Size),那么 TCP 层会进行分段处理:构建新的 skb。每一个新构建的 skb 都会拥有 自己私有的线性区,用于存储对应的协议头部(例如 TCP/IP 头部),但数据部分的内存页不会重新复制。
相反,这些 skb 会 共享同一组数据页,通过分片数组中的描述符(三元组:页指针、偏移量、长度)指向内存页中的特定区域,同时对对应数据页的 引用计数进行增加,以确保在多个 skb 引用期间该内存页不会被提前释放。
此外,在邻居解析(neighbour resolution)阶段,只要 第一个 skb 已经完成了邻居查找并关联了对应的 neighbour 结构体,那么后续由同一批数据分段生成的 skb 在发送时就可以 直接复用该邻居信息。
这是因为这些后续构建的 skb 的 目标 IP 地址完全相同,因此它们的路由结果以及对应的 下一跳设备 也必然相同。换句话说,它们都会发送到同一个下一跳设备。
因此,在这种情况下,内核无需再次重复执行以下步骤:
- 提取目标 IP 地址
- 进行哈希运算计算索引
- 查询邻居哈希表
- 获取对应的
neighbour节点
这些操作只需要在 第一个 skb 上执行一次即可。之后新构建的 skb 可以 直接复用已经解析好的 neighbour 结构体,从而减少重复的查找开销,提高发送路径的效率。
而为了防止在此期间其他 CPU 核心将该 ARP 条目删除,内核会 增加该条目的引用计数,假设此时另一个 CPU 核心正在执行 ARP表 的清理流程。该核心检查某个 ARP条目 时,发现其 引用计数为 1(仅被 ARP 表自身持有),并且该条目的状态为 FAIL 或 STALE,于是将该条目从哈希表中摘除,并将引用计数减为 0,从而触发该对象的内存回收。
如果当前核心在使用该 ARP条目 之前 没有提前增加引用计数,那么在访问该条目的过程中,就有可能遇到上述并发情况:即该条目已经被其他核心删除并释放。此时当前核心仍然访问该条目对应的内存区域,就可能访问到已经被回收或重新分配的内存,从而产生 野指针(dangling pointer)问题,进而导致不可预期的系统行为。
因此,在内核访问 ARP条目 之前,通常需要 先增加该对象的引用计数,以确保在当前使用期间该对象不会被其他核心回收。只有在使用完成之后,才会再减少引用计数,从而保证对象生命周期在并发环境下的安全性。
随后内核会填充 skb 线性区中的以太网帧头,并将 skb 的线性区以及分片数组中的数据拷贝到发送队列对应的缓冲区中。发送完成后释放 skb,同时减少 ARP 条目的引用计数。
当内核发送完该 IP 数据帧之后,还会将该 ARP 条目的状态设置为 DELAY。该状态表示当前存在在途数据,系统正在等待邻居可达性的确认。同时会启动一个定时器(通常约为 5 秒)。
如果在这段时间内没有收到邻居的确认信息,那么状态会进一步转变为 PROBE,此时内核会发送 ARP 单播探测报文 来确认对方是否仍然可达。此处发送单播的探测报文是因为此时内核手里还持有该条目的MAC地址(只是不确定是否还有效),可以直接单播发给对方确认,避免不必要的广播风暴。只有在完全没有MAC地址记录(即INCOMPLETE或FAIL状态需要重新解析)时,才需要发广播ARP请求。如果连续发送多次探测(例如 3 次)仍然没有收到响应,那么该条目的状态将被设置为 FAILED,表示该邻居不可达。
一旦进入 FAIL 状态,该 ARP条目 最终会被内核清理:减少引用计数,并从哈希表中删除。
REACHABLE → STALE → DELAY → PROBE → FAIL 而这里读者可能会疑惑:为什么会存在 stale 状态?按照直觉来看,一旦条目超时,似乎就应该被直接清理,也就是进入 fail 状态。
根据上文可以知道,其中一个原因是内核希望减少网络中的广播报文数量。除此之外,还有一个原因是:在某些情况下仍然可能收到对方的响应。
如果该数据包并不是一个过境(transit)数据报,那么就有可能直接收到来自邻居设备的响应。所谓过境数据报,是指该 IP 数据包的目标并不是当前设备的邻居,而是一个远端主机;当前设备仅作为中间节点,对数据包进行转发。而有些 IP 数据包并不属于这种情况,而是直接发送给相邻设备。例如与 OSPF 协议相关的数据报,其通信对象往往就是本地网络中的邻居路由器,而不是远端主机。举例来说,某个普通路由器向 DR 发送一个 LSR(Link State Request) 报文,DR 在收到之后会回复一个 LSU(Link State Update) 报文。
因此,如果在此期间再次收到来自下一跳设备的数据帧,并且该条目尚未彻底失效,那么该回应的数据帧中会包含以太网帧头部,其中携带了下一跳设备接口对应的 MAC 地址。这就表明对方设备仍然是可达的。此时系统可以定位到 ARP 表中的对应条目,并将其状态从 delay 更新为 reachable。
而之所以过境数据包不会触发 ARP 状态的更新,并不是因为这些数据包一定得不到响应。例如,如果过境的数据报是一个 TCP 数据报,那么理论上仍然可能收到一个 ACK 确认报文。也不是因为数据包无法在短时间内到达路由器。关键原因在于:reachable 状态的语义是“邻居双向可达”。
假设当前路由器向远端主机发送了一个数据包,远端主机随后也返回了一个 ACK 报文;同时假设网络拓扑没有发生变化,即去程与回程路径一致,并且该 ACK 报文在有效时间窗口内返回。即便如此,当该 ACK 报文到达当前路由器时,其以太网帧头中的 MAC 地址确实来自下一跳设备,但此时仍然无法据此更新对应 ARP 条目的状态。
原因就在于这里所谓的双向可达,指的是必须能够确认:我既能够接收到对方发送的数据,同时我也能够确保对方能接收到我发送的数据。这是因为在物理层面上,发送和接收这两件事在物理上是独立的。例如发送芯片可能发生故障,或者光纤链路中发送方向的通道出现断裂,那么就可能出现这样一种情况:我能够接收到对方发送的数据,但我自己发送的数据却无法成功到达对方。
因此,验证这种双向可达性的过程通常需要满足两个条件。首先,我发送的 IP 数据包必须是直接发往该下一跳设备的,也就是说 目标 IP 地址就是下一跳设备的 IP 地址。当下一跳设备收到该数据包之后,如果能够返回一个响应,就说明对方确实能够接收到我发送的数据。
同时,下一跳设备在发送响应时,其 IP 数据包的目标地址就是当前路由器的 IP 地址。当该 IP 数据包到达当前路由器时,内核在网络层本地交付路径处理该数据包的过程中,会主动检查源 IP是否命中邻居表中处于 DELAY 或 PROBE 状态的条目,如果命中则触发状态更新。具体来说,系统会提取 源 IP 地址 作为键值,在 ARP 哈希表中查找对应的条目。如果命中到对应的 ARP 条目且当前状态为 delay或 PROBE,意味着该路由器发送过IP数据包给对方路由器且并且当前正处于等待对方可达性确认的阶段。在这种情况下就可以将其更新为 reachable,因为此时已经能够确认双方之间存在有效的双向通信能力。
而如果仅仅像前文所说的那样,只是有一个过境的数据报经过该下一跳设备并到达当前路由器,那么这种情况并不能证明双向可达。因为此时该 IP 数据报并不是对方在接收到我发送的数据之后所做出的响应,它只是其他主机发送的数据报,经由该下一跳设备转发到达当前路由器。换句话说,这种情况仅仅说明下一跳设备在进行转发,相当于**“传话”的角色,并不能证明对方确实收到了我发送的数据并进行了回应,因此也就不能据此更新 ARP 表项的可达状态**。
正因为如此,过境数据包不会触发 ARP 邻居状态的更新。
那么除了上文所述通过接收一个需要本机处理的 IP 数据包来触发 ARP 邻居状态更新之外,ARP 表项的状态还可以通过其他方式发生变化。例如,当某个设备收到一个广播的 ARP 请求报文时,即使该 ARP 请求并不是发送给本机的,也仍然可能触发一种被动学习(passive learning)机制。
具体来说,设备在接收到该广播 ARP 请求后,会解析 ARP 报文有效载荷中的发送端 IP 地址和 MAC 地址。随后,以发送端的 IP 地址作为键,在本地 ARP 表中进行查询。
如果查询命中已有条目,则会进一步检查该条目当前记录的 MAC 地址:
- 如果 MAC 地址未发生变化,则不会将该条目的状态更新为
reachable。这是因为reachable状态的语义是双向可达(bidirectional reachability)。而在这种情况下,本机只是被动地接收到了对方发送的广播 ARP 报文,这只能说明我能够“听到”对方发送的报文,但并不能确认对方是否能够接收到我发送给它的报文。因此,仅凭这一信息不足以证明链路的双向可达性,所以不会更新为reachable。 - 如果 MAC 地址发生了变化,则需要对邻居状态进行调整。假设原先该条目的状态为
reachable,这意味着之前记录的 MAC 地址已经被确认是双向可达的;但现在 IP 地址对应的 MAC 地址发生了变化。在这种情况下,我们只能确认:在新的 MAC 地址对应的设备上,我能够接收到其发送的报文,但无法确认该设备是否能够接收到我发送给它的报文。因此,该条目的状态会被更新为stale。
这就是接收方通过 ARP 报文实现的被动学习机制。
此外,需要特别注意的是:这种被动学习机制仅在本地 ARP 表中已经存在对应条目时才会生效。如果本地 ARP 表中不存在该 IP 地址对应的条目,系统不会根据该 ARP 报文创建新的 ARP 表项。
这样设计的原因主要是出于安全考虑。如果允许根据任意接收到的 ARP 报文自动创建新的 ARP 表项,那么设备将很容易遭受 ARP 泛洪(ARP flooding)攻击。攻击者只需在当前广播域中持续发送伪造的 ARP 报文,并在报文中构造大量虚假的 源 IP 地址和 MAC 地址映射关系,就可能导致设备不断创建新的 ARP 表项,从而迅速耗尽 ARP 表空间,最终影响正常的网络通信。
ARP欺骗
而根据上文,我们已经介绍了 ARP 协议以及 ARP 表的基本机制。接下来将重点讨论 ARP 欺骗(ARP Spoofing)。
对于一个广播型网络(如以太网),网络中会周期性或按需产生 广播的 ARP 请求报文。如果某个黑客伪造的主机或路由器接入该广播网段,那么它同样可以接收到这些广播的 ARP 请求报文。通过解析以太网帧头以及 ARP 报文内容,攻击者便可以获取某些主机接口对应的 IP 地址与 MAC 地址的映射关系。
此外,攻击者还可以主动发送伪造的 ARP 请求报文,以此诱导网络中的设备进行 ARP 解析。例如,通过构造广播请求报文,可以诱使默认网关返回 ARP 响应,从而获取网关接口的 MAC 地址。一旦掌握了主机与网关之间的地址映射关系,攻击者便具备了实施 ARP 欺骗的基础条件。
其核心利用的机制是 免费 ARP(Gratuitous ARP)。所谓免费 ARP,本质上是一种特殊的 ARP 请求报文。其特点是:以太网帧头中的目标 MAC 地址为广播地址,源MAC地址为发送方自身的MAC地址,ARP 报文有效载荷中的 操作码(Opcode)为 1(ARP Request),并且 源 IP 地址与目标 IP 地址相同,且均为发送者自身的 IP 地址。
之所以设计这种机制,是为了在网络状态发生变化时,能够 主动通知同一网段中的其他设备更新其 ARP 缓存。由于该报文以广播形式发送,网段内所有主机在接收到该报文后,都可以根据报文中的 IP–MAC 映射关系 更新本地 ARP 表,从而确保网络中的地址解析信息保持一致与最新状态。例如:
- 当设备更换网络接口(如更换网卡)导致 MAC 地址发生变化 时,设备会主动发送免费 ARP,以通知局域网内的其他主机更新 ARP 表。
- 当接口绑定的 IP 地址发生变化 时,也可能通过发送免费 ARP 来通知邻居更新映射关系。
- 当设备刚接入网络时,也可能通过发送 ARP 请求或 ARP 响应来宣告自己的 IP–MAC 映射关系。
而 ARP 欺骗正是利用了这一机制缺乏认证与校验的特点。
攻击发生时,黑客会向目标主机 发送一个广播的 ARP 请求报文(即免费 ARP)。在该报文中,攻击者将 发送端 IP 地址伪造为默认网关的 IP 地址,但 对应的 MAC 地址填写为攻击者自身接口的 MAC 地址。
当主机收到该 ARP 报文后,会以发送端 IP 地址为键查询本地 ARP 表。如果命中对应条目,则会 更新该条目的 MAC 地址,并将其状态标记为 stale(过期状态),表示该映射关系需要在后续通信中重新确认。这样一来,主机 ARP 表中 默认网关 IP → MAC 的映射关系就被篡改为攻击者设备的 MAC 地址。
同理,攻击者还会向 默认网关发送伪造的 ARP 报文。在该报文中,发送端 IP 地址被伪造为目标主机的 IP 地址,而对应的 MAC 地址仍然填写为攻击者设备的 MAC 地址。这样一来,网关的 ARP 表也会被更新,使得 主机 IP → MAC 的映射关系指向攻击者设备。
至此,攻击者便在 主机与默认网关之间建立了一个中间人位置(Man-in-the-Middle),从而完成了双向 ARP 欺骗。
虽然现代网络通信中广泛使用 HTTPS,应用层数据会通过加密传输。即使攻击者完成了双向 ARP 欺骗,主机访问外部服务器的流量也必须经过攻击者设备,但由于攻击者并不持有服务器的私钥,因此无法直接解密 HTTPS 流量并获取其中的明文内容。
然而,即便无法解密数据,攻击者仍然可以对流量进行操控。例如,攻击者可以 直接丢弃主机发往默认网关的 IP 数据报,或者丢弃网关返回主机的数据报,从而造成通信中断。这种行为在效果上会表现为主机无法访问外部网络,即形成一种 人为制造的“断网”现象(拒绝服务效果)。
从协议设计角度来看,ARP 欺骗能够成立的根本原因在于 ARP 协议本身缺乏身份认证机制。任何设备只要能够向局域网发送 ARP 报文,就有可能影响其他主机的 ARP 缓存。因此,在实际网络环境中通常需要结合 静态 ARP 绑定、动态 ARP 检测(DAI)或交换机安全策略 等机制来缓解此类攻击风险。
ICMP
那么上文介绍了 ARP 协议相关内容,那么接下来就是 ICMP 协议。ICMP 是 Internet Control Message Protocol(互联网控制消息协议),其属于 网络层协议。
我们知道,虽然 传输层能够确保端到端之间通信的可靠性,例如通过 超时重传、拥塞控制以及滑动窗口机制等策略来保证数据可靠传输。一旦检测到丢包,也就是发送方在规定时间内没有收到对方返回的 ACK 确认报文,则会触发超时重传机制。
但是,当一个数据包从当前主机发出之后,它会进入整个网络,也就是由运营商所构建的 路由器网络基础设施。如果该数据包在网络中没有成功抵达目标主机,也就是在转发路径中丢失,那么发送方其实并不知道该数据包究竟在网络中经历了什么。
因此,对于发送方来说,有必要获知 数据包在网络中发生了什么问题。当数据包在网络中被丢弃时,例如:
- 生存时间(TTL)耗尽
- 目的地址不可达(路由器的路由表中没有匹配条目)
- 端口不可达(目标主机上没有进程监听对应端口)
此时,相关设备(通常是路由器或目标主机)会向发送方返回一个 ICMP 报文,也可以理解为一封 差错报告(error report),从而通知发送方网络中发生的具体问题。
那么 ICMP 的作用除了让发送方知晓数据包在转发路径中究竟遭遇了什么(即 差错报告功能),它还提供 网络诊断与查询功能。
例如,发送方可以查询自己与目标主机之间的网络通路是否畅通,以及目标主机是否仍然存活。具体方式是:
发送方发送一个 ICMP Echo Request(回声请求)报文到目标主机,而目标主机在收到之后会返回一个 ICMP Echo Reply(回声响应)报文。
这正是我们常用的 ping 命令所使用的机制。
因此可以简单总结三者的职责:
- ARP 协议:解决“如何把数据帧发送给本地链路上的下一跳设备”,即解析 MAC 地址。
- IP 协议:决定“数据包在网络中如何转发”,即确定下一跳路由。
- ICMP 协议:当网络转发过程中 发生异常或需要诊断网络状态时,用于返回控制信息或差错报告。
那么在了解 ICMP 报文的作用之后,接下来我们来看一下 ICMP 报文的结构。
由于 ICMP 属于 网络层协议,因此 ICMP 报文会被封装在 IP 数据报之中,而整个数据报再封装在 以太网帧中进行传输。也就是说,一个完整的 ICMP 报文在链路上的结构为:
以太网帧头 | IP报头 | ICMP报文 其中 IP 报头之后的部分就是 ICMP 的有效载荷。
需要注意的是,ICMP 报文并不包含传输层报头(既没有 TCP 头,也没有 UDP 头)。
对于 ICMP 的有效载荷来说,其最开始是一个 4 字节的 ICMP 通用首部,该首部包含三个字段:
| 字段 | 长度 | 说明 |
|---|---|---|
| 类型 (Type) | 8 bit | 大分类(如:8 代表请求,3 代表不可达) |
| 代码 (Code) | 8 bit | 小分类(如:3 里面的 1 代表主机不可达) |
| 校验和 (Checksum) | 16 bit | 用于检测 ICMP 报文在传输过程中是否出错 |
那么首先是 类型(Type)字段。
类型字段用于表示当前 ICMP 报文的 总体类型,例如:
- 是一个 查询报文
- 还是一个 差错报告报文
而 Code 字段则是在 Type 的基础上进一步细分具体原因。可以理解为:
- Type 是大类
- Code 是该类别下的具体子类型
例如,当 Type 表示“目的不可达”时,Code 则进一步指明:
- 是 网络不可达
- 还是 主机不可达
- 或者 端口不可达
而 **Checksum(校验和)**字段则用于检测 ICMP 报文在传输过程中是否发生比特错误,其计算方式与 IP 头部校验和类似。
| 类型值 | 名称(名称) | 类别 | 详细介绍与作用 |
|---|---|---|---|
| 0 | 回声回复 | 查询 | 应答:响应 Type 8 的请求,Ping 成功的标志。 |
| 3 | 目的地无法到达 | 报错 | 不可达:通知发送方该数据包在转发路径上无法到达目的地。 |
| 4 | 源头淬火 | 报错 | 源抑制:提示发送方降低发送速率(该机制现已基本废弃)。 |
| 5 | 重定向 | 报错 | 重定向:通知主机存在更优的下一跳路由。 |
| 8 | 回声请求 | 查询 | 请求:Ping 命令发送的探测报文。 |
| 9 | 路由器广告 | 查询 | 路由器向主机通告自身存在(ICMP Router Discovery)。 |
| 10 | 路由器选择 | 查询 | 主机询问本地网络中可用的路由器。 |
| 11 | 时间已逾 | 报错 | TTL 减到 0 或分片重组超时。Traceroute 的基础机制。 |
| 12 | 参数问题 | 报错 | IP 首部字段存在错误,路由器无法解析。 |
| 13 | 时间戳请求 | 查询 | 请求目标主机返回时间戳信息。 |
| 14 | 时间戳回复 | 查询 | 返回时间戳信息。 |
例如 Destination Unreachable(Type = 3):
Code 0:网络不可达(找不到到达该网络的路由)。Code 1:主机不可达(网络可达但目标主机不可达)。Code 3:端口不可达(常见于 UDP,没有进程监听对应端口)。
那么紧接在 ICMP 通用首部之后的就是 内容首部(message-specific header)。
对于 **查询类报文(如 Echo Request / Echo Reply)**来说,该部分包含:
- Identifier(标识符)
- Sequence Number(序列号)
其中 Identifier 通常用于标识发送该请求的进程(例如 ping 程序通常使用 进程 PID),而 Sequence Number 用于区分连续发送的多个探测报文。
因为发送方可能会连续发送多个查询报文,而接收方在收到之后,会返回对应的 ICMP 响应报文。
当发送方主机收到响应报文之后,数据包会经历如下处理流程:
网卡首先将接收到的 物理信号转换为数字信号,并写入网卡缓冲区,然后通过 DMA 将数据拷贝到内存。随后触发 硬件中断,CPU 切换到内核态进入网络协议栈处理流程。
内核首先构建 skb(socket buffer) 数据结构,然后解析 IP 报头,检查 目标 IP 地址是否为本机绑定地址、组播地址或广播地址。如果不匹配,则直接丢弃。
接着判断该 IP 数据报是否是 分片报文:
如果是分片,则会进入 IP 重组机制。内核会提取:
- 源 IP
- 目的 IP
- 协议号
- 标识(Identification)
这四个字段作为键值,查询分片哈希表,找到对应的重组队列,其内部通常使用 红黑树按照 **片偏移(fragment offset)**进行排序存储。
如果是 完整 IP 数据报,则继续解析 Protocol 字段,确定应该交由哪个上层协议模块处理。如果该字段为 1,则表示这是 ICMP 协议报文。
随后内核会查找 原始套接字(raw socket)表,并将该 ICMP 报文分发到 所有监听协议号为 1 的原始套接字的接收缓冲区中,最终再交付给应用层。
由于 ICMP 报文会被分发给所有监听该协议号的原始套接字,因此应用层需要根据 **Identifier(标识符)**来判断该响应是否属于自己发送的请求。
同时,由于 ICMP 本身是 网络层协议,并不像 TCP 那样提供可靠传输机制,像 ping 这样的应用发送一个序列号为 N 的请求报文时,会同时记录发送时间和该序列号。然后设置一个超时时间(通常是几秒),如果在超时时间内没有收到序列号为 N 的响应,那么 ping 程序会直接在终端输出 Request timeout for icmp_seq N,然后继续发下一个序列号的请求,不会重传。
发送方每发送一个查询报文,就会 递增序列号。
在内容首部之后则是 数据区(Data)。对于 ping 的查询报文来说,数据区通常会携带 发送时刻的时间戳。
当接收方收到该查询报文之后,会在生成 Echo Reply 时,将请求报文中的:
- 内容首部
- 数据区(包括时间戳)
原封不动复制到响应报文中。
这样,当发送方收到响应报文之后:
- 可以根据 序列号确认是哪一个请求的响应
- 读取其中的 发送时间戳
- 再结合当前时间
两者相减即可得到 ICMP 报文在网络中的往返时间(RTT, Round Trip Time)。
/* ICMP 报文完整结构 */structicmp_packet{/* --- 4 字节通用首部 --- */uint8_t type;// 类型:0(响应), 8(请求), 3(不可达), 11(超时)uint8_t code;// 代码:更详细的错误原因uint16_t checksum;// 整个 ICMP 报文的校验和/* --- 4 字节特殊字段 (联合体实现多变性) --- */union{struct{uint16_t id;// 标识符:通常放进程 PID,通常填入进程 PID 的低 16 位uint16_t sequence;// 序列号:第几个包} echo;// 用于 Ping (查询报文)uint32_t gateway;// 用于重定向 (Redirect)struct{uint16_t __unused;uint16_t mtu;// 路径 MTU 发现} frag;// 用于分片错误 (报错报文)} un;/* --- 可变长度的数据区 (Data) --- */uint8_t data[0];// 柔性数组,指向具体的载荷内容};而对于 ICMP 差错报文来说,其数据区的结构有所不同。
差错报文的数据区通常包含:
- 原始 IP 数据报的 IP 首部
- 原始数据的前 8 个字节
其中,这里的原始数据的前 8 个字节,指的是 IP 报头之后数据部分的前 8 个字节。而在绝大多数情况下,IP 报头之后紧接的就是传输层报头(例如 TCP 或 UDP 首部)。
之所以只保留 前 8 个字节,是因为在很多情况下,这一部分数据已经足以包含 传输层首部中的关键字段,例如源端口号和目的端口号。通过 原始 IP 首部 + 传输层首部中的端口号信息,操作系统通常就能够定位到触发该错误的 具体套接字(socket),从而将对应的差错信息反馈给正确的应用程序。
所以梳理一下 一个主机接收 ICMP 报文的大致流程:
网卡首先将物理信号转换为数字信号并写入接收缓冲区,然后根据 **三元组(源 IP、目的 IP、协议号)**进行哈希计算,经过 RSS / RPS 等机制确定目标接收队列,并写入对应的 环形缓冲区(ring buffer)。
随后通过 DMA 将数据复制到内存,触发 硬件中断,CPU 进入内核态处理网络协议栈。
内核读取数据并构建 skb,解析 IP 报头,判断是否属于本地主机(目标 IP 是否匹配本机地址、广播或组播)。若匹配,则继续处理。
然后判断是否为 IP 分片并进行必要的 分片重组。
接着检查 IP 协议号字段,确定上层协议。如果协议号为 1,则交由 ICMP 模块处理。
ICMP 模块解析 Type 字段:
- 如果是 Echo Request,则内核会直接构造 Echo Reply,并复制 Identifier、Sequence Number 以及数据区内容返回。
- 如果是 Echo Reply,则交付给所有监听协议号为 1 的 原始套接字,由应用层根据 Identifier 进行匹配。
- 如果是 差错报文(例如主机不可达或端口不可达),则内核会解析其数据区中的 原始 IP 报头和端口信息,定位对应的套接字并触发相应的错误处理逻辑。
最终,这些错误会反馈给应用层,例如表现为:
connect()失败send()返回错误- 或其他网络错误状态。
接下来补充一下 ICMP 的重定向(Redirect)机制。
当数据包从当前主机发送到 默认网关之后,网关会先查询本地路由表,然后根据 最长前缀匹配规则确定下一跳设备。如果此时发现 下一跳设备的 IP 地址与数据包的源 IP 地址处于同一个网段,就意味着:上一跳设备实际上可以 直接将数据包发送给该下一跳设备,而不需要先经过当前网关进行转发。
换句话说,这条路径存在一个 不必要的中间转发节点。因此,网关会向源主机发送一个 ICMP Redirect 报文,通知对方当前选择的路径并不是最优路径,从而告知源主机 下一跳应该直接发送给哪个路由器或主机。
此外需要注意的是,ICMP Redirect 只会发生在第一跳设备上,也就是 源主机的默认网关。
这是因为触发重定向的核心判断条件是:
数据包的源 IP 地址 与 通过路由表查找得到的下一跳 IP 地址 是否位于 同一个子网。
具体来说,网关在收到数据包之后,会执行如下判断逻辑:
- 查询路由表,根据 最长前缀匹配确定下一跳设备。
- 判断 数据包的源 IP 地址 与 下一跳 IP 地址 是否处于同一个子网。
如果二者处于同一个子网,则说明:
- 源主机和下一跳设备实际上是 二层直连邻居;
- 源主机完全可以 直接通过 ARP 解析下一跳的 MAC 地址并发送数据帧;
- 因此当前通过默认网关转发的路径是 次优路径。
此时网关就会向源主机发送 ICMP Redirect 报文,通知其更新本地路由缓存,以后直接将数据包发送给新的下一跳设备。
而对于 **过境数据报(transit packet)**来说,这种情况不会发生。
因为对于网络中的 中间路由器而言,数据包的 源 IP 地址通常是远端主机的地址,而当前路由器通过路由表查询得到的 下一跳 IP 地址则通常属于另一个网络段。因此:
- 源 IP 地址
- 下一跳 IP 地址
几乎不可能处于同一个子网。
由于该条件天然不满足,因此 中间路由器不会对过境数据包触发 ICMP Redirect。换句话说,ICMP 重定向只会由 源主机所在网络的第一跳网关产生,而不会在后续转发路径中的路由器上发生。
并且在这种情况下,当网关判断 源 IP 地址与下一跳设备的 IP 地址处于同一个网段 时,其并不会丢弃该数据报,而是仍然会按照原有路径 正常转发该数据包。与此同时,网关还会向源主机发送一个 ICMP Redirect 报文,用于通知对方当前所选择的路径并不是最优路径。
当源主机接收到该 ICMP Redirect 报文后,通常会将其中提供的 新的下一跳信息进行缓存。具体来说,主机会在本地的路由缓存(或路由表)中 添加一条更为具体的路由条目,使得后续发送到该目标网络的数据包可以 直接转发给新的下一跳设备,从而避免再次经过原来的网关,实现路径优化。
所以 Redirect 的本质其实是:
“你把包发给我,但其实你和真正的下一跳是邻居,你应该直接找他。”
内网穿透
接下来要探讨的内容便是内网穿透。我们知道,在企业、学校以及家庭网络内部,主机所绑定并使用的 IP 地址通常都是私有 IP 地址。当这些主机需要访问外网设备、与外部主机进行通信时,就必须将主机的私有 IP 地址以及私有端口号转换为公网 IP 地址以及公网端口号。对于默认网关而言,其会通过 NAT(Network Address Translation) 技术,根据内部维护的**地址映射表(NAT 映射表)**来确定对应的公网端口,而公网 IP 地址通常就是默认网关出口接口(即 WAN 口)所绑定的 IP 地址。
然而,如果两台位于不同内网环境中的设备希望直接进行通信,就会遇到问题。内网设备所使用的都是私有 IP 地址以及私有端口号,而私有 IP 地址在不同的内网环境中是允许重复使用的。如果仅依赖私有 IP 地址进行路由转发,那么路由器在公网环境中根本无法确定数据包应该被转发到哪一个具体的内网设备。因此,在这种场景下,就需要借助内网穿透技术来实现跨 NAT 的通信。
所谓的内网穿透技术,其核心思想是引入一个位于公网的中间服务器。位于内网中的设备会主动与该公网服务器建立连接,而外部客户端也通过该服务器与目标内网设备进行间接通信。换句话说,公网服务器充当了一个中继节点(relay):
一个内网设备先将数据发送给公网服务器,而公网服务器再将数据转发给目标的内网设备,从而实现跨越 NAT 的通信。
以 frp 为例,其实现方式大致如下:
首先,内网客户端 frpc 会主动与服务端 frps 的控制端口建立一条控制连接。随后,客户端会通过该控制连接向服务端发送注册信息,例如声明自己希望在服务端暴露一个端口(假设为 6000)。
在服务端侧,当主线程 accept 到该控制连接后,通常会创建一个独立的控制线程专门负责处理与该客户端之间的控制通信,而主线程则继续监听控制端口,以接收新的客户端连接。
当控制线程接收到客户端发送的“注册端口 6000”的请求后,就会在服务端执行 listen(6000),开始监听该端口,以等待外部客户端的连接。
那么这里还需要注意 accept 的处理逻辑。在实际实现中,accept 返回的连接既可能是 frpc 建立的工作连接,也可能是外部客户端建立的连接,因此服务端在 accept 成功之后,必须先对该连接进行一次简单的身份识别。
通常的做法是:在连接建立之后,客户端会先发送一段标识信息。服务端在 accept 成功后先执行一次 read 读取该标识信息,然后根据内容判断该连接的类型。
如果该连接是 frpc 的工作连接,那么 frpc 会主动发送一个标识字段,例如 "工作连接",服务端识别到该标识之后,就可以确认当前文件描述符对应的是工作连接。此时只需要从等待队列中取出一个已经到达的外部客户端连接,然后将两者进行配对,并创建一个新的线程负责双向转发即可。
而如果该连接是 外部客户端连接,则说明当前连接只是用户访问服务端暴露端口产生的连接。由于此时不一定已经存在对应的工作连接,因此需要先将该连接暂存到等待队列中。随后服务端通过控制连接通知 frpc:当前有新的客户端到达,需要建立新的工作连接。
所以需要引入等待队列,是因为在真实网络环境中,两类连接到达服务端的顺序是不确定的。例如,外部客户端 A 和 B 可能先后连接到服务端,frps 在检测到这两个连接后会连续两次通知 frpc 建立对应的工作连接。但 frpc 建立新的 TCP 工作连接本身需要一定时间,在此期间,服务端可能已经收到了多个外部客户端连接,因此这些连接就需要暂时存放在等待队列中。
例如,可能出现以下几种典型情况:
- 外部客户端 A 与 B 先后连接到服务端,服务端连续两次通知 frpc 建立工作连接,但 frpc 的工作连接尚未建立完成,此时队列中会暂存 A 和 B 的连接。
- 也可能是 A 先连接到服务端,服务端立即通知 frpc 建立工作连接,但在 frpc 的工作连接尚未到达之前,B 又连接了进来,因此队列中会先后存放 A 和 B 两个连接。
因此,服务端需要通过一个队列结构来暂存这些尚未完成配对的外部客户端连接。当 frpc 的工作连接到达时,服务端再从队列中取出一个等待中的外部连接,与该工作连接进行配对,并建立对应的数据转发通道。这样即使两类连接的到达顺序存在差异,也能够保证连接能够被正确匹配并建立稳定的数据转发链路。
整体流程可以描述如下:
// frps 主线程listen(控制端口) fd0 =accept() → 得到控制连接 // 控制连接的处理线程fun(fd0,...){read(fd0) → 收到注册信息 "请监听6000端口"listen(6000端口)// 等待队列,存放尚未配对的外部客户端连接 queue =[]while(1){ fd =accept()read(fd, buf)if buf =="外部客户端": queue.push(fd)// 通知 frpc 有新的客户端到达write(fd0,"有新客户端来了")elseif buf =="工作连接":// 从队列中取出等待的外部客户端 fd1 = queue.pop()// 创建线程进行双向转发new_thread(双向转发(fd1, fd))// 如果队列中仍然存在等待的客户端// 继续通知 frpc 建立新的工作连接if queue.not_empty():write(fd0,"还有新客户端在等待")}}需要补充说明的是,这种设计本质上是一种连接配对机制(connection pairing):
- 外部客户端连接先到达时,会被放入等待队列;
- frpc 工作连接到达后,会从队列中取出一个外部连接进行配对;
- 一旦配对成功,服务端便启动一个独立的线程(或协程)在两个文件描述符之间执行透明的双向转发。
通过这种方式,服务端能够在连接到达顺序不确定的情况下,依然正确地完成外部连接与内网工作连接之间的匹配,从而保证内网穿透的数据通道能够被正确建立。
这里需要注意的是,6000 端口会一直处于监听状态。每当 accept 到一个新的外部客户端连接时,服务端都会通过控制连接通知 frpc。随后 frpc 会主动再建立一条新的连接到 6000 端口,从而形成一对新的连接。
最终,服务端会将这两次 accept 得到的文件描述符进行一一配对绑定,并为其创建一个独立的数据转发通道。每一个外部客户端连接都会对应一条独立的 frpc 工作连接,从而形成多个点对点的数据转发链路。
而这里需要注意的是,frpc 所在的主机本质上是一个内网服务器,也就是说它是真正提供业务服务的一方。虽然在与公网服务器建立连接的过程中,frpc 的角色表现为客户端(因为它主动向 frps 发起 connect 连接),但如果从整个内网穿透系统的业务逻辑来看,它实际上承担的是服务提供者的角色。
换句话说,TCP 连接中的“客户端 / 服务端”只是连接建立阶段的角色划分(谁主动 connect、谁 listen),而在应用层语义中,谁提供业务能力、谁发起业务请求,则是另一种角色划分。因此,对于 frp 架构来说:
- frpc 虽然在网络连接上是客户端,但在业务语义上更接近于内网服务器;
- 外部客户端 才是真正的业务请求方;
- frps 则只是一个公网中的连接中继与转发节点,本身并不提供具体业务。
因此,可以从三个维度来理解整个系统中的角色划分:
| 角色名称 | 物理位置 | 核心动作 | 真实身份 |
|---|---|---|---|
| frps | 公网(固定 IP) | Listen(监听来自各方的连接) | 管道的中转站 |
| frpc | 内网(私有 IP) | Connect(主动连接公网服务器) | 业务的真正提供者 |
| 外部客户端 | 任意网络 | Connect(连接 frps 暴露的端口) | 业务的请求者 |
从整体结构上看,frps 更像是一个公网的“交换节点”或“流量中继器”。所有连接都会先汇聚到 frps,然后再由 frps 将外部客户端连接与 frpc 的工作连接进行配对,并在两者之间建立数据转发通道。
因此,外部客户端在逻辑上虽然是“连接 frps”,但实际上访问的仍然是内网 frpc 所提供的服务;frps 只是负责在公网中建立一条稳定的数据转发通道,从而实现跨 NAT 的通信能力。这也是内网穿透技术的核心思想:通过公网节点,将原本无法直接建立连接的两个 NAT 后的主机间接连接起来。
内网打洞
那么根据上文,我们已经介绍了 frp 内网穿透 的基本原理。其核心思想是:利用一台具有公网 IP 的服务器作为中继节点,通过该服务器对数据进行转发,从而实现公网与内网之间的通信。除了这种通过中继服务器进行数据转发的内网穿透方式之外,另一种常见的技术手段是 内网打洞(NAT Hole Punching),它同样可以在一定条件下实现两个内网设备之间的直接通信。
所谓 内网打洞,其基本流程是:两个位于内网中的设备首先分别与一台位于公网的服务器进行通信,从而借助该服务器完成彼此的地址信息交换。假设其中一台内网主机作为服务端,其会主动与公网服务器建立一个长连接用于注册自身信息,例如注册一个唯一的 身份 ID。当该连接建立时,公网服务器能够从接收到的 IP 数据包中提取出 源公网 IP 地址以及源端口号(即 NAT 映射后的地址信息),随后在自身的数据结构(例如哈希表)中记录一条映射关系:
ID → (public_ip, public_port) 也就是说,服务器维护了一个从 身份 ID 到 公网地址与端口号 的映射表。
而对于外部客户端而言,如果希望与该内网服务端通信,则需要先向公网服务器发送一个查询请求,请求内容为 查询指定 ID 对应的公网 IP 地址以及端口号。服务器在收到查询请求之后,会根据该 ID 在哈希表中进行查找。如果查找成功,就会将对应的 公网 IP 地址以及端口号 返回给请求方。
与此同时,由于公网服务器能够从查询请求的 IP 报文头部获取到 请求方的公网 IP 地址以及端口号,因此服务器也可以同时将该信息通知给另一端,例如告知内网服务端:“有一个客户端希望与你通信,其公网地址为 (client_ip, client_port)”。至此,双方都已经获得了对方的公网地址信息。
接下来就进入 打洞阶段。在这一阶段,双方会几乎同时向对方的公网地址发送数据包。当数据包从内网主机发送到默认网关(即 NAT 设备)时,网关会在其 NAT 映射表 中创建一条映射记录。例如:
(private_ip, private_port) → (public_ip, public_port) 同样地,对方主机也会执行相同的操作,即向对方发送数据包,从而在各自的 NAT 设备中建立映射条目。一旦双方 NAT 表中的映射都建立成功,那么来自对方公网地址的数据包就可以被 NAT 正确映射回内部主机。此时通信路径就被“打通”,双方即可进行正常的数据通信。
需要注意的是,这里的 NAT 映射表 在实现上通常也是一种 哈希表结构。根据不同实现策略,NAT 的映射类型通常可以分为两大类:对称型 NAT(Symmetric NAT) 和 圆锥型 NAT(Cone NAT)。
对于 对称型 NAT 而言,其映射表的键通常是 五元组:
(src_ip, src_port, dst_ip, dst_port, protocol) 当一个数据包到达 NAT 设备时,设备会将该五元组与某个随机数进行混合运算,然后经过哈希计算并取模,从而得到 哈希桶索引。随后,NAT 设备还会通过类似的哈希运算生成一个 公网端口号。接着构建一个 反向五元组:
(public_ip, public_port, dst_ip, dst_port, protocol) 其中 public_ip 通常是网关 WAN 接口的 IP 地址。随后以该反向五元组为键,在哈希表中检查是否发生冲突。
根据对称 NAT 的映射规则可以推导出一个重要结论:只要访问的目标 IP 地址或端口不同,NAT 就可能分配不同的公网端口号。这就会导致 NAT 打洞过程中出现问题。
主机 A 先访问公网服务器,此时 NAT 设备会为该连接分配一个公网端口,例如 1000,并在 NAT 映射表(通常实现为哈希表结构)中插入对应的正向映射和反向映射条目。
公网服务器随后将 A 的公网地址 (A_ip, 1000) 告知 B,同时也会将 B 的公网地址 (B_ip, B_port) 告知 A。假设 B 在访问服务器时被 NAT 分配的公网端口也是 1000。
此时 B 会尝试向 (A_ip, 1000) 发送数据包。但由于 B 之前只访问过公网服务器,当其向 (A_ip, 1000) 发送数据包时,在 对称 NAT 的规则下,这属于一个新的五元组,因此 NAT 会重新建立映射,并可能分配一个新的公网端口,例如 2000。
同样地,A 也会尝试向 (B_ip, B_port) 发送数据包。但由于 目标地址从公网服务器变成了 B 的公网地址,对于 A 的 NAT 设备来说,这同样是一个新的五元组,因此也会重新计算映射,并可能分配一个新的公网端口,例如 2000。
而这里 A 会向 B 发送一个五元组为 (A_ip, 2000, B_ip, 1000, 协议) 的数据报。该数据报随后到达 B 的 NAT 网关,但 B 的网关所维护的 NAT 映射表中并不存在与该五元组对应的映射条目。
同理,B 也会向 A 发送一个五元组为 (B_ip, 2000, A_ip, 1000, 协议) 的数据报。然而,此时 A 的 NAT 网关中仅记录了 A 访问 B 时新建立的映射关系,而最初通过服务器建立的 (A_ip, 1000) 映射只允许来自服务器的返回流量。
因此,当双方尝试直接向对方发送数据包时,这些数据包的 五元组信息都无法匹配对方 NAT 表中已有的映射条目。在对称 NAT 的策略下,未命中映射的入站数据包会被 NAT 设备直接丢弃。
因此,双方发送的数据包都无法被正确映射回内网主机,最终导致 NAT 映射表中的“打洞”过程失败,双方无法建立直接通信。
而对于 圆锥型 NAT(Cone NAT),其映射规则则要宽松得多。此时 NAT 表中的键通常不是五元组,而是 二元组:
(src_ip, src_port)对应的值则是:
(public_ip, public_port)其映射建立过程与对称 NAT 的计算流程类似,只是参与哈希计算的字段减少为二元组。简化后的逻辑流程如下:
来了一个包,取出二元组 (src_ip, src_port) ↓ hash(二元组 ⊕ 随机数)% 桶数 ↓ 得到哈希索引 ↓ public_port =hash(二元组 ⊕ 随机数)% 端口范围 ↓ 构建反向二元组 (public_ip, public_port) ↓ 以反向二元组为键计算索引,遍历链表 ↓ 冲突? ↙ ↘ 没冲突 冲突 ↓ ↓ 插入两条 public_port++ 表项 重复上述过程 ↓ 正向:(src_ip, src_port) → (public_ip, public_port) 反向:(public_ip, public_port) → (src_ip, src_port)因此,在 对称 NAT 中,只有当返回数据包 属于同一个连接或同一个流 时,NAT 才会允许其通过。而在 圆锥 NAT 中,只要数据包的目标地址为 (public_ip, public_port),NAT 都可能允许其进入并映射回内部主机。因此,从形象上看,这种结构类似于一个“圆锥”:顶部是固定的 (public_ip, public_port),而来自不同来源的流量只要指向这个地址,就都可以进入该“锥体”的内部。
正因为如此,圆锥 NAT 的安全性相对较低,但其对 P2P 和 NAT 打洞技术更加友好。
对于一个 NAT 网关而言,通常 只会实现一种映射策略。不会维护两张哈希表,根本不存在合理的决策依据,所以双表并存的设计本身就没有意义。因此,大多数 NAT 设备都会选择一种固定的实现方式。
在现实网络环境中,绝大多数家用路由器通常采用的是圆锥 NAT。而一旦处于对称 NAT 环境下,NAT 打洞技术往往很难成功。因此,在这种情况下,通常仍然需要依赖 内网穿透技术(如 frp),通过公网服务器进行数据中继,从而实现稳定可靠的通信。
代理服务器
而文章最后要探讨的是代理服务器。代理服务器通常分为正向代理服务器和反向代理服务器两种类型。其中,正向代理在现实网络环境中较为常见,例如校园网环境就是一个典型的例子。
在校园网络中,学校通常会在不同区域(例如图书馆、教学楼、宿舍区等)部署接入路由设备。当用户需要访问外部互联网服务器时,本地主机首先将数据发送到本地网关(通常是接入路由器)。随后,该 IP 数据包会被转发到学校的核心网络,例如位于学校机房中的核心路由设备或统一出口设备,最终再由学校的出口链路转发至运营商网络。从逻辑结构上看,学校机房中的出口设备就承担了一个代理的中间节点角色,因此可以将其视为一种正向代理服务器的实现形式。
所谓正向代理服务器(Forward Proxy),是指代理服务器位于客户端与外部服务器之间,并代表客户端向外部服务器发起请求。在这种模式下,客户端通常不会直接与目标服务器建立连接,而是先将请求发送给代理服务器。
在协议层面上,请求报文中的目标信息通常仍然指向最终要访问的服务器(例如 HTTP 请求中的 Host 字段或完整 URL),代理服务器在接收到数据后,会解析其应用层有效载荷(例如 HTTP 请求报文),从中获取真正的目标服务器地址。随后,代理服务器再以自己的身份向目标服务器发起请求,并在收到响应后再转发回客户端。
因此,从外部服务器的视角来看,请求的来源实际上是代理服务器,而不是最初的客户端。这也是正向代理常见用途之一,例如:
- 统一出口访问控制
- 内容过滤与审计
- 隐藏内部客户端的真实地址
而反向代理服务器(Reverse Proxy)的工作方式则恰好相反。反向代理通常部署在服务器侧,对外表现为一个统一的服务入口。客户端在访问某个服务时,实际上连接的是反向代理服务器,而不是后端的真实业务服务器。
当反向代理服务器接收到客户端请求后,会根据一定的策略(例如 URL 路径、请求头信息、负载情况等)将请求转发到后端的某一台服务器。例如在一个大型网站架构中,后端可能部署了多台提供相同服务的应用服务器。反向代理服务器可以根据负载均衡算法(如轮询、加权轮询、最少连接等)将请求分发到不同的后端节点,从而实现负载均衡。
总结来说:
- 正向代理:代理的是客户端,帮助客户端访问外部资源。
- 反向代理:代理的是服务器,对外提供统一的服务入口,并将请求分发到后端服务器。
从网络架构的角度来看,两者虽然都属于“代理”,但其部署位置、服务对象以及设计目标都有明显区别。
结语
那么这就是本篇文章的全部内容,带你全面认识以及掌握ARP以及ICMP协议以及内网穿透,至此网络原理便告一段落,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!
