实现 Shell 远程登录(SSH)

2020-05-26T13:27:13+08:00

SSH (Secure Shell) 由 rlogin (Remote Login) 进化而来,透过它用户可以使用远程系统里的程序,比如 Vim。SSH 相当于终端版的远程桌面。

它究竟是如何工作的?抛开用户认证部分,SSH 使用 Unix 伪终端(Pseudo-Terminal)实现,SSH 分成服务器 sshd 和客户端 ssh,用户用 ssh 登陆之后,sshd 生成一个伪终端设备,Vim 等程序运行在这个伪终端设备里,服务器客户端通过网络把这个伪终端和用户的终端绑定起来。

服务器 sshd

posix_openpt(3) 打开一个伪终端设备,得到一对主从设备。用 fork(2) 得到的子进程控制从设备,和 Bash 绑定起来,而进程操作控制主设备,和 Socket 绑定起来。

其中我把伪终端初始大小设置成了 25 行 80 列,正确的做法应该是跟用户的终端大小保持一致。而且,我也不清楚伪终端初始该用什么样的 termios,也更用户的保持一致?

/* sshd.c --- SSH 服务器 */
#define _GNU_SOURCE             /* posix_openpt */
#include <arpa/inet.h>          /* inet_ntoa */
#include <fcntl.h>              /* open */
#include <netinet/in.h>         /* in_addr */
#include <signal.h>             /* kill */
#include <stdio.h>              /* perror */
#include <stdlib.h>             /* exit */
#include <sys/ioctl.h>          /* ioctl */
#include <sys/socket.h>         /* socket */
#include <termios.h>            /* struct termios */
#include <unistd.h>             /* fork */

#define PORT 8888
#define MAXMSG 512

static void
die(char* s)
{
    perror(s);
    exit(EXIT_FAILURE);
}

int
main(void)
{
    int sockfd = socket (AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1)
        die ("socket");

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons (PORT),
    };
    addr.sin_addr.s_addr = htonl (INADDR_ANY);

    if (bind (sockfd, (struct sockaddr *) &addr, sizeof addr) == -1)
        die ("bind");

    if (listen (sockfd, 5) == -1)
        die ("listen");

    printf("Listening on %s port %d\n", inet_ntoa(addr.sin_addr), PORT);

    while(1) {
        int fd = accept(sockfd, NULL, NULL);
        if (fd == -1) die("accept");

        printf("Accept connection\n");

        int masterFd = posix_openpt(O_RDWR | O_NOCTTY);
        if (masterFd == -1) die("posix_openpt");
        if (grantpt(masterFd) == -1) die("grantpt");
        if (unlockpt(masterFd) == -1) die("unlockpt");
        char* slname = ptsname(masterFd);
        if (slname == NULL) die("ptsname");

        pid_t cpid = fork();
        if (cpid == -1) die("fork");

        if (cpid == 0) {        /* Child */
            if (setsid() == -1) die("setsid");
            close(masterFd);
            int slavedFd = open(slname, O_RDWR);
            if (slavedFd == -1) die("open slave pseudo-terminal");

            if (ioctl(slavedFd, TIOCSCTTY) == -1)
                die("ioctl TIOCSCTTY 设置控制终端");

            struct winsize ws = {
                .ws_col = 80,   /* XXX 应该设置成客户端用户所使用的终端的大小 */
                .ws_row = 25,
            };
            if (ioctl(slavedFd, TIOCSWINSZ, &ws) == -1)
                die("ioctl TIOCSWINSZ 窗口大小");

            if (dup2(slavedFd, STDIN_FILENO) == -1) die("dup2");
            if (dup2(slavedFd, STDOUT_FILENO) == -1) die("dup2");
            if (dup2(slavedFd, STDERR_FILENO) == -1) die("dup2");

            execlp("bash", "bash", "-i", NULL);
            perror("execlp");
            _exit(EXIT_FAILURE);
        }

        /* Parent */
        while(1) {
            fd_set read_fd_set;
            FD_ZERO(&read_fd_set);
            FD_SET(fd, &read_fd_set);
            FD_SET(masterFd, &read_fd_set);

            if (select(masterFd+1, &read_fd_set, NULL, NULL, NULL) == -1)
                die("select");

            if (FD_ISSET(fd, &read_fd_set)) {
                char buffer[MAXMSG];
                ssize_t nread = read(fd, buffer, MAXMSG);
                if (nread == -1)
                    die("read");
                else if (nread == 0) {
                    fprintf(stderr, "got end-of-file from client, closing\n");
                    close(fd);
                    break;

                } else {
                    write(masterFd, buffer, nread);
                }
            }

            if (FD_ISSET(masterFd, &read_fd_set)) {
                char buffer[MAXMSG];
                ssize_t nread = read(masterFd, buffer, MAXMSG);
                if (nread == -1)
                    die("read");
                else if (nread == 0) {
                    fprintf(stderr, "got end-of-file from terminal, quit\n");
                    exit(EXIT_FAILURE);
                } else {
                    if (write(fd, buffer, nread) != nread)
                        die("partial/failed write");
                }
            }
        }
        kill(cpid, SIGTERM);
    }
}

客户端 ssh

nc(1)telnet(1) 可以做简单的测试,但会遇到终端上的问题,比如需要按 RET 才会发送。我写了个客户端,主要工作就是进入 Raw mode、把 Socket 和终端的 IO 绑定起来。

/* ssh.c --- SSH 客户端 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <termios.h>
#include <unistd.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <errno.h>

static void
die(char* s)
{
    perror(s);
    exit(EXIT_FAILURE);
}

struct termios user_termios;

static void
enable_raw_mode(int fd)
{
    struct termios t;
    if (tcgetattr(fd, &t) == -1) die("tcgetattr");

    user_termios = t;

    t.c_lflag &= ~(ICANON | ECHO | ISIG | IEXTEN);
    t.c_iflag &= ~(ICRNL | INLCR | IXON);
    t.c_oflag &= ~OPOST;
    t.c_cc[VMIN] = 1;
    t.c_cc[VTIME] = 0;

    if (tcsetattr(fd, TCSAFLUSH, &t) == -1)
        die("tcsetattr");
}

static void
disable_raw_mode(void)
{
    if (tcsetattr(STDOUT_FILENO, TCSAFLUSH, &user_termios) == -1)
        die("tcsetattr");
}

int
main(int argc, char* argv[])
{
    if (argc < 3 || strcmp(argv[1], "--help") == 0) {
        fprintf(stderr, "usage: %s host port\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    char* host = argv[1];
    errno = 0;
    int port = strtol(argv[2], 0, 10);
    if (errno != 0) die("strtol");

    struct in_addr iaddr;
    switch (inet_pton (AF_INET, host, &iaddr))
        {
        case -1:
            die("inet_pton");
        case 0:
            printf("IPv4 domain %s is not parsable\n", host);
            exit(EXIT_FAILURE);
        }

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_addr = iaddr,
        .sin_port = htons(port),
    };

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) die("socket");

    printf("Connecting %s port %d\n", inet_ntoa(addr.sin_addr), port);

    if (connect(sockfd, (struct sockaddr*)&addr, sizeof addr) == -1)
        die("connect");

    enable_raw_mode(STDOUT_FILENO);
    if (atexit(disable_raw_mode) == -1)
        die("atexit");

#define BUF_SIZE 256
    char buf[BUF_SIZE];

    while (1) {
        fd_set inFds;
        FD_ZERO(&inFds);
        FD_SET(STDIN_FILENO, &inFds);
        FD_SET(sockfd, &inFds);

        if (select(sockfd + 1, &inFds, NULL, NULL, NULL) == -1)
            die("select");

        if (FD_ISSET(STDIN_FILENO, &inFds)) {
            ssize_t numRead = read(STDIN_FILENO, buf, BUF_SIZE);
            if (numRead <= 0)
                exit(EXIT_SUCCESS);

            if (write(sockfd, buf, numRead) != numRead)
                die("partial/failed write");
        }

        if (FD_ISSET(sockfd, &inFds)) {
            ssize_t numRead = read(sockfd, buf, BUF_SIZE);
            if (numRead <= 0)
                exit(EXIT_SUCCESS);

            if (write(STDOUT_FILENO, buf, numRead) != numRead)
                die("partial/failed write");
        }
    }
}

测试举例:

# 服务器
$ cc sshd.c -o sshd
$ ./sshd
Listening on 0.0.0.0 port 8888

# 客户端
$ cc ssh.c -o ssh
$ ./ssh 192.168.1.103 8888

参考

The Linux Programming Interface 的最后一章介绍了伪终端,包括 SSH 如何用伪终端实现的。