监听状态 UDP socket

使用 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 没有 listenaccept 接口,从图片我们能看到 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;

注意区分 socketsock 两个结构体及它们的状态字段 socket->statesock->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(&current_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(&current_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_LISTENSS_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?

https://stackoverflow.com/questions/14388706/socket-options-so-reuseaddr-and-so-reuseport-how-do-they-differ-do-they-mean-t

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