Search K
Appearance
Appearance
我们知道客户端主动发起请求 connect 时,操作系统会为它分配一个临时端口(ephemeral port)。在 linux 上 这个端口的取值范围由 /proc/sys/net/ipv4/ip_local_port_range 文件的值决定,在我的 CentOS 机器上,临时端口的范围是 32768~60999。
有两种典型的使用方式会生成临时端口:
先来看 bind 调用的例子,故意注释掉端口的赋值,完整的代码如下。
int main(void) {
int listenfd;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl (INADDR_ANY);
// 这里故意注释掉端口的赋值
// servaddr.sin_port = htons (9090);
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
listen(listenfd, 5);
clilen = sizeof(cliaddr);
accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
sleep(-1);
return 1;
}编译执行上面的代码,使用 netstat 可以看到 linux 自动为其分配了一个临时的端口 40843。
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:40843 0.0.0.0:* LISTEN 21608/./a.out再来看第二个例子客户端 connect,使用 nc 或者 telnet 访问本地或远程的服务时,都会自动分配一个临时端口号。比如执行 nc localhost 8080 访问本机的 web 服务器,随后使用 netstat 查看连接状态,可以看到分配了临时端口号 37778。
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:37778 127.0.0.1:8080 ESTABLISHED 22126/nc接下来的内容以 connect 为例,linux 内核版本是 3.10.0。核心的代码在 net/ipv4/inet_hashtables.c 中,为了方便我做了部分精简。
int __inet_hash_connect(struct sock *sk, u32 port_offset) {
int low; // 临时端口号的下界
int high; // 临时端口号的上界
static u32 hint; // 使用静态变量保存的递增值,减少 offset 冲突的可能性
// port_offset 是根据源地址、目的地址、目标端口计算出的哈希值
u32 offset = hint + port_offset;
int port;
// 读取 /proc/sys/net/ipv4/ip_local_port_range 的临时端号的上界和下界
inet_get_local_port_range(net, &low, &high);
// remaining 是临时端口号可分配值的范围
int remaining = (high - low) + 1;
/* By starting with offset being an even number,
* we tend to leave about 50% of ports for other uses,
* like bind(0).
*/
offset &= ~1; // 将最后一位置为 0
int i;
// 从 0 开始遍历,查找未被占用的端口号
for (i = 0; i < remaining; i++) {
// 保证 port 的范围是在 low~high 之间
port = low + (i + offset) % remaining;
// 检查端口号是否属于保留端口号
if (inet_is_reserved_local_port(port))
continue;
// 接下来检查端口是否被占用、等逻辑
if (all_ok) {
goto ok;
}
}
ok:
// 下次 connect 时 hint 递增,减少端口号冲突的概率
hint += (i + 2) & ~1;
}其中传入的 port_offset 的计算逻辑是在 net/core/secure_seq.c 的 secure_ipv4_port_ephemeral 方法中实现的,代码如下。
u32 secure_ipv4_port_ephemeral(__be32 saddr, __be32 daddr, __be16 dport)
{
u32 hash[MD5_DIGEST_WORDS];
net_secret_init();
hash[0] = (__force u32)saddr; // 源地址
hash[1] = (__force u32)daddr; // 目标地址
hash[2] = (__force u32)dport ^ net_secret[14]; // 目标端口号
hash[3] = net_secret[15];
md5_transform(hash, net_secret); // 计算 MD5 值
return hash[0];
}因为此时还没有源端口,这个函数使用源地址、目标地址、目标端口号这三个元素进行 MD5 运算得到一个 offset 值,通过同一组源地址、目标地址、目标端口号计算出的 offset 值相等,这也是为什么需要加入地址 hint 的原因,否则使对同一个目标端口服务同时进行请求时,第一次 for 循环计算出来的端口都是一样的。加入了递增的 hint 以后,就可以避免这种情况了。
以一次实际的计算为例,经过调试 linux 内核,在某一次 telnet localhost 2000 过程中,分配到的临时端口号是 48968,如下所示。
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:48968 127.0.0.1:2000 ESTABLISHED 16475/telnet下面看下计算的过程。
// offset = 32 + 266836801 = 0xfe79b61
u32 offset = hint + port_offset;
// offset = 0xfe79b60
offset &= ~1; // 将最后一位置为 0
// port = 32768 + (0 + 0xfe79b60) % 28232
// port = 32768 + 16200 = 48968
port = low + (i + offset) % remaining;如果短时间内大量 connect,耗尽了所有临时端口号会发生什么?我们来实测一下。
使用 sysctl 修改 ip_local_port_range 的范围,只允许分配一个端口 50001,如下所示。
sudo sysctl -w net.ipv4.ip_local_port_range="50001 50001"使用 nc 或者 telnet 等工具发起 TCP 连接,这里使用 nc -4 localhost 22,使用 netstat 查看当前连接信息,可以看到分配的临时端口为 50001,如下所示。
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:50001 127.0.0.1:22 ESTABLISHED 18605/nc再次执行 nc 发起连接,可以看到这次失败了,如下所示。
nc -4 localhost 22
Ncat: Cannot assign requested address.使用 strace 查看 nc 命令系统调用。
strace nc -4 localhost 22系统调用如下所示。
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
fcntl(3, F_GETFL) = 0x2 (flags O_RDWR)
fcntl(3, F_SETFL, O_RDWR|O_NONBLOCK) = 0
connect(3, {sa_family=AF_INET, sin_port=htons(22), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EADDRNOTAVAIL (Cannot assign requested address)
...可以看到 connect 调用返回了 EADDRNOTAVAIL 错误。使用 golang 的代码和结果如下所示。
package main
import (
"fmt"
"net"
"time"
)
func main() {
// 仅使用 ipv4
_, err := net.Dial("tcp4", "localhost:22")
if err != nil {
fmt.Println(err)
}
time.Sleep(time.Minute * 10)
}编译运行上面的 go 代码结果如下所示。
dial tcp4 127.0.0.1:22: connect: cannot assign requested address