tty之ssh的基本实现原理

前言

linux服务器用户直接使用终端与服务器交互, 用户可以执行各种命令, 如果电脑上装的是linux无桌面版本, 那么点击开机键, 不久就能见到屏幕上提示登陆的信息, 用户登陆后能进行各种操作, 这个黑色的命令行界面就是终端。

此外, 桌面版本的linux用户虽然没有直接使用终端, 却能够用类似konsole的终端模拟器与终端交互。此外我们在本地的linux终端上输入ssh 对端服务器的ip, 能够连接到远端服务器, 也能与终端进行交互。这两种场景都离不开伪终端的作用

阅读本文可以知道伪终端的基本概念, 知道如何实现一个终端模拟器, 知道如何实现一个ssh或者screen那样的软件, 下面是前置知识, 务必阅读。

要阅读这篇文章, 还需要对linux的session, signal, shell有一定的了解。

终端模拟器的简要图景

终端模拟器是模拟终端(命令行模式下真正的终端, 这种终端是一种硬件支持, 如果我们没有安装桌面, 我们访问的用户接口就叫终端)的一种软件, 它可以用多种多样的桌面应用框架实现界面, 比如qt, electron等。实现终端模拟器需要使用内核提供的pty设备, 这种设备可以用来实现终端模拟器或ssh, screen之类的软件。

pty就是伪终端的意思, 我们按下CTRL-ALT-F2切换的终端是真正的终端。而比如我们在桌面上打开的konsole是一个经典的终端模拟器。

要创建一个终端模拟器, 需要两个进程, 父进程负责渲染界面, 子进程负责执行程序(使用exec命令)。下面的demo可以看出父子进程的职能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <pty.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main() {
int master;
int slave;
char name[256];
struct winsize ws = {
.ws_col = 30,
.ws_row = 30
};

openpty(&master, &slave, name, NULL, &ws);

printf("%d %d %s\n", master, slave, name);
if (fork() == 0) {
// 子进程

// 先关闭之前所有从父进程拿到的fd, 这里粗略估计一下
for (size_t i = 0; i < 10; i++) {
close(i);
}


// 开启一个新的session, 此进程成为session leader, session可以有一个控制终端
// setsid将丢弃之前的session, "自立门派"
setsid();
// 关闭从父进程拷贝过来的012描述符, 开这个终端自己的描述符
close(0);
close(1);
close(2);
// 如果这个进程是session leader且没有控制终端, 打开pty设备, 会使此进程成为伪终端的控制终端
open(name, O_RDWR, 0); // stdin
dup(0); // stdout
dup(0); // stderr
// 运行shell
execl("/bin/bash", NULL);
} else {
// 父进程
while(1) {
sleep(3);
char buf[100];
write(master, "abc\n", 4);
int n = read(master, buf, 100);
buf[n] = '\0';
printf("%s\n", buf);
}
}
}

/* 输出
3 4 /dev/pts/18
[markity@mycom tty]$ abc
bash: abc: command not found
[markity@mycom tty]$ abc
bash: abc: command not found
... 更多循环消息
*/

我们可以认为, 一个终端模拟器的父进程需要从主设备文件中读, 用读出来的内容渲染界面(比如用一些如qt的桌面应用框架画图), 捕获用户按键, 将内容写入主设备文件(简而言之, 终端模拟器就是对master进行io操作, 包括read, write, ioctl, 并画界面)。

子进程需要借助打开的从设备文件(成为伪终端的控制终端), 用dup系统调用分别获得stdin(fd=0), stdout(fd=1)和stderr(fd=2), 并且执行exec系统调用进入shell。

可以总结: 终端其实就是个cs架构的”服务器”, 终端模拟器只是读写master设备, 然后绘画界面, 将键盘事件写入到master设备做通信罢了。

我们也可以看到, 子进程通过拷贝(dup)从设备文件得到了stdin, stdout, stderr。难道它们其实是一个东西? 我们平时写c语言代码, 可是认为它们有着完全不同的意义。下面来验证一下, 看看是否能向stdin写入内容:

1
2
3
4
5
6
7
8
#include <stdio.h>
#include <unistd.h>

int main() {
write(STDIN_FILENO, "hello\n", 6);
}

// 输出hello\n

最终结果很让人惊讶, 确实能写!那么可以说stdin, stdout, stderr其实代指同一个设备文件对象。但为了符合标准, 将其分为了三个不同的fd, 暗含不同的逻辑含义。我们平时可能不会注意到这点, 0, 1, 2代表同一个设备确实让我耳目一新。

终端的种类

现在打开你的终端模拟器, 比如konsole, 输入echo $TERM, 可以得到以下输出:

1
2
[root@mycom markity]# echo $TERM
xterm-256color

现在按CTRL-ALT-F2, 在命令行模式下做同样的操作:

1
2
[root@mycom markity]# echo $TERM
linux

我们来解释这些输出:

  1. 终端有不同的种类, 例如xterm-256color, vt100, linux
  2. 不同种类的终端拥有不同的能力
  3. 某种终端所拥有的能力通过/usr/share/terminfo的数据库查询

自终端诞生以来, 兼容性就需要被考虑, 因此有数据库存储了各种各样终端的”能力”。我们桌面上终端模拟器的终端类型是xterm-256color, 它有很多linux终端没有的能力。比如我们可以支持鼠标, 这就是终端模拟器的拓展。

当终端模拟器大小改变?

当我们使用终端模拟器时, 我们可以改变终端模拟器的窗口大小, 然而vim似乎能正常获悉窗口大小改变, 做出响应式布局, 这是什么原理?

先来阐述步骤, 终端模拟器改变大小时, 父进程将做出以下操作:

1
ioctl(master, TIOCSWINSZ, &win_size_struct);

内核收到后, 将给伪终端的前台进程发送SIGWINCH, 此时vim就能获悉终端大小改变, 然后做出响应了。

实现ssh的简要图景:

我们知道, ssh分为客户端和服务端。服务器运行sshd这个守护进程, 我们在本地的终端运行ssh这个命令就能连接到远端, 随后输入用户名和密码就能像操纵本地终端一样操作远端linux服务器了。

现在不考虑加密, 只考虑简单实现, 我们来捋顺一下步骤

其中客户端要有下面的步骤:

0.将自身终端直接设置成raw mode(此时line discipline不发送任何信号, 也不会有行缓冲, 用户输入的字符立即被程序接收, 相当于用户的每个按键产生的字节都被内核原封不动地写入0号描述符, 我们可以从0读出字节序列)。并且设立SIGWINCH的信号handler, 在接收到信号的时候, 调用ioctl, 命令用TIOCGWINSZ就能获得新的宽高。
1.与服务端建立tcp连接, 发送自身终端的宽高
2.然后不断从socket读, 写入文件描述符0(其实0,1,2是一个东西, 写入0,1,2没区别的), 不断从文件描述符0读, 写入socket(这里要多路复用)。当SIGWINCH响应时, 也应该读出新的宽高, 发送给服务端。这里发送给服务器的数据包类型分两种, 一种是WINCH事件, 一种是用户正常读写, 要把包分成两种类型通知服务器

其中服务端要有以下步骤:

1.监听tcp连接, 接收客户端发来的宽高, 并初始化pty设备
2.开启子进程, 把stdin, stdout, stderr安排好。调用exec执行/bin/bash
3.父进程负责从socket读, 写入0文件描述符, 且不断从0文件描述符读, 写入socket(此处多路复用)。父进程需要分析包的类型, 如果是WINCH事件, 那么ioctl(master, TIOCSWINSZ, &win_size_struct);。如果是正常写入, 那么write给master文件描述符

简要代码实现

点击此处, 这是一个go版本的简易实现, 能够响应窗口大小改变。

参考资料