剑客
关注科技互联网

浅析 OpenSSH 的 Multiplexing 的实现

背景

Multiplexing(多路复用)是 OpenSSH 众多不为人知的冷门功能之一,但用过的人都会为其所带来的便利而赞不绝口。本文在向读者简要介绍 Multiplexing 功能的使用之余,更是把注意力集中在了其实现之上, 以飨 那些和笔者一样好奇心旺盛的读者,并希望类似实现方法未来可以推广应用到更多的应用之上。

Multiplexing 功能简介

在 OpenSSH 中 Multiplexing 功能支持同一主机的多个 SSH 会话共享单一 TCP 连接进行通讯,一旦第一个连接建立,后续连接就不再需要凭证,从而消除了每次连接同一机器都需要键入密码的麻烦并且大幅度节省了服务器端的资源。

设置 Session Multiplexing

在客户端节点如下配置/etc/ssh_config 或~/.ssh/config 就可以直接开启 Session Multiplexing 功能:

清单 1. 配置 Session Multiplexing

Host *                                  #该部分的定义将应用到全部主机
	ControlMaster yes                       #Session Multiplexing 开关
	ControlPath   ~/.ssh/master-%r@%h:%p    #供 Session Multiplexing 使用的 Control Socket (Unix Socket) 路径
	ControlPersist yes                      #是否开启后台 Control master 模式

成功开启后,无论从该客户端节点用同一用户向同一 SSH Server 节点发起多少次连接,都有且仅有一条连接被建立,负责该节点到该 Server 之间的所有 SSH 包文。

下面我们给出一个实际案例:如清单 2 所示,设有一个 SSH 服务器节点:9.115.241.18,一个 SSH 客户端节点:9.115.241.20。在客户端的 Session Multiplexing 开启的情况下,向服务器端建立若干个 SSH 会话,最后在客户端或服务器端检测实际存在的连接数,会发现整个过程仅仅有一条 TCP 被建立起来。

清单 2. Multiplexing 实际使用案例

# 注:以下的操作均在客户端节点(9.115.241.20)完成
	# 建立一个连接到服务器端,定期执行 ls 命令打印出当前目录下的文件
	  $ ssh zhang@9.115.241.18 'while /bin/true; do ls; sleep 1; done > /dev/null' &[1] 11551
	# 再建立另一个连接到服务器端,执行同样的操作
	  $ ssh zhang@9.115.241.18 'while /bin/true; do ls; sleep 1; done > /dev/null' &[2] 11638
	# 最后,显示出当前节点接入服务器端(9.115.241.18)22 端口(SSH 协议)的所有连接
	  $ netstat -n | grep '9.115.241.18:22'
	  tcp        0      0 9.115.241.18:22         9.115.241.20:52148      ESTABLISHED

由清单 2 最后的 netstat 命令可知,无论由客户端发起多少个 SSH 进程,都只会有一条连接建立起来,而这一条连接便承载了 9.115.241.20 到服务器端的所有 SSH 通讯。

以上就是 OpenSSH 的 Session Multiplexing 功能的简要介绍,由于本文的主旨是介绍其实现,所以对于功能方面的细节这里就不再赘述了,有兴趣的读者可以移步参考文献 1 进一步学习。

讲到这里,相信很多读者应该会对 Multiplexing 的实现充满好奇吧。其实仔细想来不难发现,常规来说构建连接的套接字(socket)一般为每一个进程所独有,倘若意图在不同的进程间共享连接(即所谓的 Session Multiplexing),则需要解决下述两个问题:

  1. 设计一个机制,使得单一连接上可以承载从属于不同应用(进程)的报文;
  2. 构造一个实现,使得抵达接收端的报文可以从容在各个应用(进程)之间无缝分发;

下面笔者将从上述两个问题出发,一层一层的抽丝剥茧,并最终向读者揭开 Multiplexing 实现的谜团。

OpenSSH 的 Channel Mechanism

首先,让我们来探讨问题 1:如何在一条连接上承载多个不同应用(进程)报文。

这个问题的答案其实已经存在于 SSH 协议(参考文献 2、3)之中了,协议的起草者们给出的答案是:Channel。

Channel 的原理

Channel 是 SSH 的连接层中的一项非常重要的机制,它本身是基于 SSH 协议的连接层的一套逻辑实现。在任意一个节点,当需要发起新的 SSH 传输时都可以打开一个 Channel,而每一个 Channel 都有一个唯一的数字标识(identifier),且每一个隶属于某一 Channel 的报文,也都需要包含该 Channel 在接收端的 identifier,以保证能够准确送达具体的应用。

简而言之,Channel 可以被理解为对 SSH 连接的一次再分割,每一个基于 SSH 的通信会话都可以利用不同的 Channel 来共享同一条连接,以保证对网络资源的最大化利用。

在 SSH 中,所有一切的通信会话,包括 terminal session,connection forward 都是利用 Channel 实现的。

如图 1 所示,Channel 使得同一节点上的多个进程分享同一条连接成为了可能,可以说 Channel 机制是 SSH 的 Multiplexing 实现的基石。

图 1. 多进程下的 Multiplexing(多路复用)

浅析 OpenSSH 的 Multiplexing 的实现

浅析 OpenSSH 的 Multiplexing 的实现

讲完原理,我们再来基于 OpenSSH 的源码简单探讨一下 Channel 的实现,为了利于理解,下文的所有讨论均基于 interactive session 的实现。

Channel 的实现

OpenSSH 中 Channel 模块的数据结构及主要接口大多声明于 channels.h ,而实现于 channels.c ,其实现内容庞杂,限于篇幅,不宜在本文中一一抄录,为便于读者理解源码,笔者于表 1 中摘录了一些 channel 数据结构 中的关键接口:

表 1. channel structure 摘录

Data member Category 描述
type Channel 类型,点击 这里 查看所有 channel 类型声明
self Channel identifier 本地 channel identifier
remote_id 远端 channel identifier
istate/ostate Connection status 分别表述接收端状态(istate)、传输端状态(ostate)
flags 连接关闭状态
rfd/wfd/efd/sock File descriptor 和当前 Channel 关联的输入、输出、错误等文件描述符
input/output/extend Buffer Channel 中定义的 I/O 缓存
remote_window/remote_maxpacket TCP data flow TCP 数据流控制信息
local_window/local_windowmax TCP data flow TCP 数据流控制信息
local_consumed/local_maxpacket TCP data flow TCP 数据流控制信息
ctl_chan Multiplexer 表述用于 multiplexer 的 channel 对象
mux_rcb Multiplexer Multiplexer 的读回调函数
mux_ctx Multiplexer 表述 Multiplexer 进程的状态

有经验的读者看到这里大抵就可以猜测出 channel 其实就是一个数据倒爷,主要工作就是在其管理的应用的文件描述符(rfd/wfd/efd 等)和 I/O 缓存(input/output 等)之间来回倒腾数据,此外,每一个在 Channel 的缓存中往来的数据报文都以相应的 id(self/remote_id)来标识自己的身份,以确保能够正确的传递。

上述整个过程基本实现于 client_loop() 函数(实现于 clientloop.c )之中。理解了 client_loop()就基本可以把 Channel 模块的各个接口串联起来了。但是这一部分的代码篇幅较长,这里笔者仅将大体流程归纳为图 2,以便读者能先有一个大致的印象,有兴趣的读者可以移步参考文献 4 做进一步学习。

图 2. client_loop()的大体实现

浅析 OpenSSH 的 Multiplexing 的实现

浅析 OpenSSH 的 Multiplexing 的实现

最后,相信也有细心的读者发现表 1 的末尾列出了若干和 multiplexer 实现相关的接口,其实 OpenSSH 的 Multiplexing 功能也的确复用了 Channel 模块的部分代码,这部分内容笔者将在下一个章节中进行深入探讨。

OpenSSH 的 Multiplexing 实现

接下来,我们探讨问题 2:如何保证经由 Channel 模块接收到的数据在各个应用(进程)之间从容分发?

显而易见,由于 OpenSSH 客户端的实现基本按照一进程一连接的模式,故同某一连接紧密相关的所有 Channel 对象自然也是必须关联在同一进程空间之中的。这就对其他使用 Multiplexing 功能的进程提出了 IPC(Inter-Process Communication,进程间通讯)的实现需求。

由上文清单 1 中的 ControlPath 项定义可以发现,OpenSSH 选择了 Unix 套接字来作为其 IPC 的方法,仅由一个进程创建指向 SSH Server 的连接,我们称之为控制进程(Control / Multiplexing Master Process);而其余需要利用该连接进行传输的进程则经由 Unix 套接字控制 Master 进程建立对应的 Channel 对象,发送和接受相应报文,其实现如图 3 所示:

图 3. OpenSSH 的 Multiplexing 实现

浅析 OpenSSH 的 Multiplexing 的实现

浅析 OpenSSH 的 Multiplexing 的实现

由图 3 不难看出,控制进程作为 Server 端,由 muxserver_listen()建立,掌握着和 SSH Server 通信的唯一连接;其他进程是 Client 端,由 muxclient()建立,通过 ControlPath 设置的套接字和控制进程通讯,实现对连接的多路复用。

值得注意的是根据配置的不同(Control Persist Enabled/Disabled),OpenSSH 中的控制进程分为前台和后台两种模式,在图 3 中笔者以实线(前台)和虚线(后台)分别标识。在前台模式下,控制进程就是第一个向 SSH Server 建立远程连接的进程(即图中 Process 1);而后台模式下,控制进程则由 Process 1 fork 出来,之后包括 Process 1 在内的所有进程都需要通过 muxclient()和控制进程进行 IPC。

控制进程前后台的实现靠 ssh.c 中的 control_persist_detach(),有兴趣的读者可以点击 这里 查看源码。

回归正题,诚然, muxserver_listen() (查看清单 3)和 muxclient() (查看清单 4)是解读 Multiplexing 实现的重要突破口,其中所实现出的 CS 机制是非常教科书式的,篇幅所限,笔者仅在下文列出部分代码(其中的中文注释为笔者所加):

清单 3. muxserver_listen()

void muxserver_listen(void)
{
    /*……*/
    old_umask = umask(0177);
    /*监听 unix 套接字*/
    muxserver_sock = unix_listener(options.control_path, 64, 0);
    /*……*/
    /*为 muxserver 套接字创建一个专有的 channel 对象用于管理 IPC 通信*/
    mux_listener_channel = channel_new("mux listener",
        SSH_CHANNEL_MUX_LISTENER, muxserver_sock, muxserver_sock, -1,
        CHAN_TCP_WINDOW_DEFAULT, CHAN_TCP_PACKET_DEFAULT,
        0, options.control_path, 1);
    /*mux_master_read_cb()函数为 control master server 端的
     通用 read control block, 用于处理 client 发来的所有 command*/
    mux_listener_channel->mux_rcb = mux_master_read_cb;
    /*……*/
}

在清单 3 中可以看到,如前文所述,mux 模块复用了 channel 模块来管理 unix 套接字的通信,并建立了两个专用的 channel 类型:

  • SSH_CHANNEL_MUX_LISTENER: 用于在 multiplex master 进程中管理 server 端套接字,每有一个新连接就新建立一个 SSH_CHANNEL_MUX_CLIENT 类型的 channel;
  • SSH_CHANNEL_MUX_CLIENT: 用于在 multiplex master 进程中管理 client 端的套接字,利用上文所述的 mux_master_read_cb()来处理 client 发来的所有 command;

更多的实现细节,可以参考在 channels.c 中的初始化函数 channel_handler_init_20() ,中可以看到这两个 channel 类型所对应的 channel_pre 和 channel_post 函数的定义,并据此进一步展开学习。

清单 4. muxclient()

void muxclient(const char *path)
{
    /*……*/
    /*连接 muxserver 中创建的 unix 套接字开始通讯*/
    if ((sock = socket(PF_UNIX, SOCK_STREAM, 0)) < 0)
        fatal("%s socket(): %s", __func__, strerror(errno));
    /*地址 addr 由参数 path 复制而来*/
    if (connect(sock, (struct sockaddr *)&addr, sun_len) == -1) {
        /*连接失败,退出*/
    }
    /* 握手 */
    if (mux_client_hello_exchange(sock) != 0) {
        /*……*/
    }
    /* Mux Command Process */
    switch (muxclient_command) {
    case SSHMUX_COMMAND_ALIVE_CHECK: 	/*……*/
    case SSHMUX_COMMAND_TERMINATE: 	/*……*/
    case SSHMUX_COMMAND_FORWARD: 		/*……*/
    case SSHMUX_COMMAND_OPEN: 		/*……*/
    case SSHMUX_COMMAND_STDIO_FWD: 	/*……*/
    case SSHMUX_COMMAND_STOP: 		/*……*/
    case SSHMUX_COMMAND_CANCEL_FWD: 	/*……*/
    }
}

在代码段末尾的 switch 块中,列举出了所有为 mux 模块所定义的带外通信命令,由于本文讨论的范畴仅限于 interactive session 的实现,所以最后一部分的讨论主要集中在 SSHMUX_COMMAND_OPEN 命令的服务器端( process_mux_new_session() )和客户端( mux_client_request_session() )的实现上。

首先我们来介绍 mux_client_request_session()的大致流程,如清单 5 所示:

清单 5. mux_client_request_session()

static int mux_client_request_session(int fd)
{
    /*……*/
    /* 发送 MUX_C_NEW_SESSION 命令到 Control Master Process */
    buffer_init(&m);
    buffer_put_int(&m, MUX_C_NEW_SESSION);
    buffer_put_int(&m, muxclient_request_id);
    /*……*/
    if (mux_client_write_packet(fd, &m) != 0)
        fatal("%s: write packet: %s", __func__, strerror(errno));
    /* Send the stdio file descriptors */
    /* 这一步非常重要:把当前进程的 stdin/out/err 共享给 Multiplex Master 进程 */
    if (mm_send_fd(fd, STDIN_FILENO) == -1 ||
        mm_send_fd(fd, STDOUT_FILENO) == -1 ||
        mm_send_fd(fd, STDERR_FILENO) == -1)
        fatal("%s: send fds failed", __func__);   
    /*……*/
    /* Stick around until the controlee closes the client_fd */
    for (exitval = 255, exitval_seen = 0;;) {
        buffer_clear(&m);
        if (mux_client_read_packet(fd, &m) != 0)
            break;
        type = buffer_get_int(&m);
        switch (type) {
        case MUX_S_EXIT_MESSAGE:        /* End of the Session */
        /*……*/
        }
    }
    /*当进程运行到这里的时候,已经基本宣告完结了*/
    close(fd);
    /*……*/
    exit(exitval);
}

mux_client_request_session()里只做了两件事:

  • 发送 MUX_C_NEW_SESSION 到 Multiplex Master 进程;
  • 等待 MUX_S_EXIT_MESSAGE 信号,结束进程;

值得注意的是在发送 MUX_C_NEW_SESSION 的同时,mux_client_request_session()还将当前进程的标准输入、输出和错误文件描述符作为参数共享给了 Multiplex Master 进程,这是整个设计的关键,在 process_mux_new_session()(如清单 6 所示)中我们将看到这样做的理由:

清单 6. process_mux_new_session()

static int
process_mux_new_session(u_int rid, Channel *c, Buffer *m, Buffer *r)
{
    /*……*/
    /*由缓存中获取 IPC 报文*/
    if ((reserved = buffer_get_string_ret(m, NULL)) == NULL ||
        /*……*/) {
        /*……*/
        return -1;
    }
    /*……*/
    /* Gather fds from client */
    for(i = 0; i < 3; i++) {
        if ((new_fd[i] = mm_receive_fd(c->sock)) == -1) {
            /*……*/
            return -1;
        }
    }
    /*……*/
    /* 利用 client 进程的标准输入、输出和错误
        创建一个新的 SSH_CHANNEL_OPENING channel 对象,
        如此则可以直接由 channel 的 buffer 将 IO 输入/输出到正确的 fd 了 */
    nc = channel_new("session", SSH_CHANNEL_OPENING,
        new_fd[0], new_fd[1], new_fd[2], window, packetmax,
        CHAN_EXTENDED_WRITE, "client-session", /*nonblock*/0);   
    /*……*/
    /* reply is deferred, sent by mux_session_confirm */
    return 0;
}

由清单 6 可以看出,接收了由 client 进程发送而来的三个文件描述符之后,控制进程最终创建并关联给了一个新的 Session 类型的 Channel 对象。如此这般,客户端进程就可以直接利用这些文件描述符作为通道直接和控制进程上下文中的 SSH 连接进行通讯了。

小结

基于 SSH 协议中的 Channel 设计而实现的 OpenSSH 的 Multiplexing 是一个相对冷僻却又非常强大的功能。本文参考了相关的 RFC 文档以及 OpenSSH 的源码对其实现的概要进行了一番粗浅的分析。

随着越来越多的控制路径的应用开始采用 SSH 协议对远程控制节点进行控制,这一技术开始逐渐运用于生产环境,例如 Ansible 就可以通过配置来支持 OpenSSH 的 Multiplexing 功能以提高其部署效率。而作为为数不多的实现了进程间多路复用的 SSH 应用,研究 OpenSSH 的 Multiplexing 实现对于在其他主流 SSH 应用(eg. Paramiko)中实现类似功能,进而改进系统整体性能有着很重要的借鉴意义。

参考资源

  1. OpenSSH CookbookMultiplexing 章节,这里有更加详尽的关于 Multiplexing 功能的介绍;
  2. RFC4251 : The Secure Shell (SSH) Protocol Architecture,SSH 协议架构设计;
  3. RFC4254 : The Secure Shell (SSH) Connection Protocol,SSH 协议连接层设计;
  4. OpenSSH 源代码 ,本文所参考的是 OpenSSH 最新的 7.3 版本的代码,读者进一步学习时请注意选择正确的分支(V_7_3);
分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址