runsisi's

technical notes

监听状态 UDP socket

2020-01-06 runsisi#tcp/ip

使用 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 客户端编程方法):

udp1

udp2

注意到 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