使用 ss 命令(或者类似的工具 netstat)查看 UDP socket 信息时,如果使用 -l
选项,我们可能会看到很多处于“监听状态”的 UDP socket:
# ss -nupl
State Recv-Q Send-Q Local Address:Port Peer Address:Port
UNCONN 0 0 *:58395 *:* users:(("client",pid=2092,fd=3))
很多对 UDP socket 不了解的同学,都以为是软件的问题,是一个潜在的安全隐患,实际上这只是将自己的 TCP 知识套用在 UDP 上而产生的谬误。
UDP socket 状态变迁
ss 的 socket 信息来源是通过 netlink 从 inet_diag
/ tcp_diag
/ udp_diag
等内核态 ko 获取的,其 fallback 方法是通过读取 /proc/net/tcp(6),/proc/net/udp(6) 等 procfs 暴露的信息获得,而这两者最终都需要遍历内核的 socket 哈希表。显然,为了理解 UDP socket 状态的变迁,我们需要阅读 UDP 相关的内核代码。
首先,我们看一下 UDP socket 编程用到的 socket 接口(下面两张图片对应两种不同的 UDP 客户端编程方法):
注意到 UDP socket 编程和 TCP socket 编程最大的不同没有?UDP socket 没有 listen
和 accept
接口,从图片我们能看到 UDP socket 客户端与服务端所用到的接口几乎没有差别(如果对 socket 编程比较熟悉的话,显然知道客户端显式 bind 来指定源地址是很常见的操作,而服务端的话都是 bind 来指定源端口或者同时指定源地址和源端口)。
下面对这些 socket 接口(特指 UDP socket,后文相同)对 socket 状态变迁的影响进行一个简要的介绍。
socket 创建时,状态为 TCP_CLOSE
(显然 UDP 复用了 TCP 的状态定义):
// net/ipv4/af_inet.c
inet_create(struct net *net, struct socket *sock, int protocol, int kern)
sock_init_data(sock, sk)
sk->sk_state = TCP_CLOSE;
注意区分 socket
、sock
两个结构体及它们的状态字段 socket->state
、sock->sk_state
的不同,ss 用到的是 sock->sk_state
字段,因此这里及后文提到的 socket 状态都是指 sock->sk_state
字段所代表的状态。
bind
,不改变 socket 状态。
connect
,如果没有显式 bind 端口,则调用 inet_autobind
找到一个随机端口进行隐式绑定(受 net.ipv4.ip_local_port_range
约束,同时排除 net.ipv4.ip_local_reserved_ports
),然后调用 ip4_datagram_connect
通过路由子系统选择本地源地址:
// net/ipv4/datagram.c
inet_dgram_connect(struct socket *sock, struct sockaddr *uaddr, int addr_len, int flags)
struct sock *sk = sock->sk;
if (!inet_sk(sk)->inet_num && inet_autobind(sk))
return -EAGAIN;
// ip4_datagram_connect
return sk->sk_prot->connect(sk, uaddr, addr_len);
ip4_datagram_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
__ip4_datagram_connect(sk, uaddr, addr_len)
if (!inet->inet_saddr)
inet->inet_saddr = fl4->saddr; /* Update source address */
if (!inet->inet_rcv_saddr) {
inet->inet_rcv_saddr = fl4->saddr;
if (sk->sk_prot->rehash)
sk->sk_prot->rehash(sk);
}
inet->inet_daddr = fl4->daddr;
inet->inet_dport = usin->sin_port;
sk->sk_state = TCP_ESTABLISHED;
最后,socket 状态转换成 TCP_ESTABLISHED
。不过需要注意的是 UDP 是无连接的协议,这里的 connect 仅用于确定 socket 五元组,而不会像 TCP 一样真的发起 SYN 三次握手。
send
/sendto
,如果没有显式 bind 端口或者通过 connect 隐式 bind 端口,则调用 inet_autobind
找到一个随机端口进行隐式绑定:
inet_sendmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg, size_t size)
struct sock *sk = sock->sk;
/* We may need to bind the socket. */
if (!inet_sk(sk)->inet_num && !sk->sk_prot->no_autobind && // for TCP no_autobind is true, for UDP it is false
inet_autobind(sk))
return -EAGAIN;
return sk->sk_prot->sendmsg(iocb, sk, msg, size);
但 socket 状态不会发生改变。
recv
/recvfrom
不改变 socket 状态。
综上,socket 创建时状态为 TCP_CLOSE
,只有 connect 能够改变 socket 状态为 TCP_ESTABLISHED
,不存在所谓的“监听状态”的 UDP socket。
UDP socket 与 ss
回到 ss 的问题,分析为何使用 -l
选项能看到处于“监听状态”的 UDP 客户端。
ss 查看 UDP socket 信息常用的选项组合如下:
# ss -nup
# ss -nupl
# ss -nupa
我们看一下 ss 的 -l
和 -a
两个选项的说明文档:
$ man ss
-l, --listening
Display only listening sockets (these are omitted by default).
-a, --all
Display both listening and non-listening (for TCP this means established connections) sockets.
显然,man 手册页对 -l
的说明并没有区分 UDP 和 TCP,而这实际上就是混乱的来源。
那么,下面我们深入到 ss 的代码来看一看,这几个选项到底意味着什么:
case 'u':
filter_db_set(¤t_filter, UDP_DB, true);
break;
case 'a':
state_filter = SS_ALL;
break;
case 'l':
state_filter = (1 << SS_LISTEN) | (1 << SS_CLOSE);
break;
filter_states_set(¤t_filter, state_filter);
static const struct filter default_dbs[MAX_DB] = {
[UDP_DB] = {
.states = (1 << SS_ESTABLISHED),
.families = FAMILY_MASK(AF_INET) | FAMILY_MASK(AF_INET6),
},
};
static void filter_db_set(struct filter *f, int db, bool enable)
{
if (enable) {
f->states |= default_dbs[db].states;
f->dbs |= 1 << db;
} else {
f->dbs &= ~(1 << db);
}
do_default = 0;
}
static void filter_states_set(struct filter *f, int states)
{
if (states)
f->states = states;
}
当指定选项 -a
时,显示状态为 SS_ALL
的 socket(意即所有状态),当指定选项 -l
时,仅显示状态为 SS_LISTEN
或 SS_CLOSE
的 socket,而当这两个选项都不指定时,则仅显示状态为 SS_ESTABLISHED
的 socket(当然,需要说明的是,ss 命令行还有非常多的选项可能影响 socket 状态的过滤,这里仅讨论前面提及的常用选项)。
然后,我们对比一下内核定义的 socket 状态与 ss 定义的 socket 状态(如下),显然两者的枚举值定义是一一对应的关系。
内核定义的 socket 状态:
// include/net/tcp_states.h
enum {
TCP_ESTABLISHED = 1,
TCP_SYN_SENT,
TCP_SYN_RECV,
TCP_FIN_WAIT1,
TCP_FIN_WAIT2,
TCP_TIME_WAIT,
TCP_CLOSE,
TCP_CLOSE_WAIT,
TCP_LAST_ACK,
TCP_LISTEN,
TCP_CLOSING, /* Now a valid state */
TCP_MAX_STATES /* Leave at the end! */
};
ss 定义的 socket 状态:
enum {
SS_UNKNOWN,
SS_ESTABLISHED,
SS_SYN_SENT,
SS_SYN_RECV,
SS_FIN_WAIT1,
SS_FIN_WAIT2,
SS_TIME_WAIT,
SS_CLOSE,
SS_CLOSE_WAIT,
SS_LAST_ACK,
SS_LISTEN,
SS_CLOSING,
SS_MAX
};
所以,ss 的 -l
选项之所以把 UDP 客户端 socket 打印出来了,只是因为 -l
对 UDP socket 而言就是过滤 SS_CLOSE
状态的 socket,而结合前面对内核中 UDP socket 状态变迁的总结,UDP 客户端 socket 除非显式 connect,否则它永远都处于 TCP_CLOSE
,也即 SS_CLOSE
,状态。实际上,这一点也可以从 ss 打印的第一列 socket 状态字段得到印证:
# ss -nupl
State Recv-Q Send-Q Local Address:Port Peer Address:Port
UNCONN 0 0 *:58395 *:* users:(("client",pid=2092,fd=3))
static void sock_state_print(struct sockstat *s)
const char *sock_name;
static const char * const sstate_name[] = {
"UNKNOWN",
[SS_ESTABLISHED] = "ESTAB",
[SS_SYN_SENT] = "SYN-SENT",
[SS_SYN_RECV] = "SYN-RECV",
[SS_FIN_WAIT1] = "FIN-WAIT-1",
[SS_FIN_WAIT2] = "FIN-WAIT-2",
[SS_TIME_WAIT] = "TIME-WAIT",
[SS_CLOSE] = "UNCONN",
[SS_CLOSE_WAIT] = "CLOSE-WAIT",
[SS_LAST_ACK] = "LAST-ACK",
[SS_LISTEN] = "LISTEN",
[SS_CLOSING] = "CLOSING",
};
显然,这个 UDP 客户端 socket 只是处于 SS_CLOSE
状态而已,它和我们所理解的 TCP socket 的“监听状态”没有任何联系。同样的,UDP 服务端 socket 基本只可能处于 SS_CLOSE
状态,因为如果服务端显式 connect 了,那这个服务端就只能对某一个客户端服务了。而事实上,除非为了使用 recv
/send
来简化 recvfrom
/sendto
的调用,即使是客户端编程,我们也极少会显式调用 connect。
还有一个问题需要考虑,安全,正是因为基于对安全的考虑,才让我如此的重视 -l
的输出。既然 UDP 客户端与 UDP 服务端本质上没有什么差异,那不就相当于任何人(注意 ss 的 Peer Address:Port
字段)都可以与这个客户端进行交互了吗?没错,确实是这样。对于单播的 UDP 业务而言,客户端可以使用显式的 connect 进行规避,但对于多播业务而言,这个方法行不通。但是,这里我们要考虑的一个问题是,为何我们很少担心 TCP 客户端端口开放的问题?,一是因为 TCP 面向连接的特性让我们的机器只允许对应服务端的报文得以通过,二是状态防火墙对这一条件进行了加强,显然,对 UDP 客户端而言,由于协议层无法保证过滤非法报文,那么状态防火墙才是安全的保障(更多的信息可以阅读 netfilter conntrack、iptables 相关的文档)。
参考资料
The SO_REUSEPORT socket option
https://lwn.net/Articles/542629/
Bind: Address Already in Use
https://hea-www.harvard.edu/~fine/Tech/addrinuse.html
Setting the SO_REUSEADDR Option
http://alas.matf.bg.ac.rs/manuals/lspe/snode=104.html
Socket options SO_REUSEADDR and SO_REUSEPORT, how do they differ?
udp: secondary hash on (local port, local address)
https://github.com/torvalds/linux/commit/512615b6b843ff3ff5ad583f34c39b3f302f5f26
net: introduce reciprocal_scale helper and convert users
https://github.com/torvalds/linux/commit/89770b0a69ee0e0e5e99c722192d535115f73778
内核对TCP REUSEPORT的优化
https://www.cnblogs.com/miercler/p/5543190.html
Linux 最新SO_REUSEPORT特性
https://www.cnblogs.com/Anker/p/7076537.html
UDP Server-Client implementation in C
https://www.geeksforgeeks.org/udp-server-client-implementation-c/
UDP Client Server using connect | C implementation
https://www.geeksforgeeks.org/udp-client-server-using-connect-c-implementation/
Using poll() instead of select()
https://www.ibm.com/support/knowledgecenter/ssw_ibm_i_72/rzab6/poll.htm
Example: Nonblocking I/O and select()
https://www.ibm.com/support/knowledgecenter/ssw_ibm_i_72/rzab6/xnonblock.htm
Example of client/server with select().
https://gist.github.com/Alexey-N-Chernyshov/4634731
最后修改于 2020-01-06