你好!前几天我们讨论了当你按下终端中的一个键时发生的事情。
作为后续,我认为实现一个类似于小型 ssh 服务器但没有安全性的程序可能会很有趣。你可以在 github 上找到它,我将在这篇博文中解释它是如何工作的。
目标:“ssh”到远程计算机
我们的目标是能够登录到远程计算机并运行命令,就像使用 SSH 或 telnet 一样。
该程序与 SSH 之间的最大区别在于实际上没有安全性(甚至没有密码)——任何可以与服务器建立 TCP 连接的人都可以获得 shell 并运行命令。
显然这在现实生活中不是一个有用的程序,但我们的目标是更多地了解终端的工作原理,而不是编写一个有用的程序。
(不过,我将在下周在公共互联网上运行它的一个版本,你可以在这篇博文的末尾看到如何连接它)
让我们从服务器开始吧!
我们还将编写一个客户端,但服务器是有趣的部分,所以让我们从那里开始。我们将编写一个侦听 TCP 端口(我选择 7777)的服务器,并为连接到它的任何客户端创建远程终端以供使用。
当服务器接收到新连接时,它需要:
- 创建一个伪终端供客户端使用
- 启动一个
bashshell 进程供客户端使用 - 将
bash连接到伪终端 - 在 TCP 连接和伪终端之间不断地来回复制信息
我刚才说了很多“伪终端”这个词,所以让我们谈谈它的含义。
什么是伪终端?
好吧,到底什么是伪终端?
伪终端很像双向管道或套接字——你有两端,它们都可以发送和接收信息。您可以阅读更多有关发送和接收信息的信息,如果您按下终端中的某个键会发生什么
基本上这个想法是,在一端,我们有一个 TCP 连接,而在另一端,我们有一个bash shell。所以我们需要将伪终端的一部分挂接到 TCP 连接,另一端挂接到 bash。
伪终端的两个部分称为:
- “伪终端大师”。这是我们要连接到 TCP 连接的结尾。
- “从属伪终端设备”。我们要将 bash shell 的
stdout、stderr和stdin设置为此。
一旦它们连接起来,我们就可以通过我们的 TCP 连接与bash通信,我们将拥有一个远程 shell!
为什么我们仍然需要这个“伪终端”东西?
您可能想知道 – Julia,如果伪终端有点像套接字,为什么我们不能将 bash shell 的stdout / stderr / stdin设置为 TCP 套接字?
你可以!我们可以编写一个这样的 TCP 连接处理程序来完成此操作,它不需要很多代码( server-notty.go )。
func handle(conn net.Conn) { tty, _ := conn.(*net.TCPConn).File() // start bash with tcp connection as stdin/stdout/stderr cmd := exec.Command("bash") cmd.Stdin = tty cmd.Stdout = tty cmd.Stderr = tty cmd.Start() }
它甚至可以工作——如果我们使用nc localhost 7778连接到它,我们可以运行命令并查看它们的输出。
但是有几个问题。我不打算列出所有这些,只列出两个。
问题1:Ctrl + C 不起作用
Ctrl + C 在远程登录会话中的工作方式是
- 你按 ctrl + c
- 它被翻译成
0x03并通过 TCP 连接发送 - 终端接收
- 另一端的 Linux 内核注释“嘿,那是 Ctrl + C!”
- Linux 将
SIGINT发送到适当的进程(稍后详细了解“适当的进程”是什么)
如果“终端”只是一个 TCP 连接,这是行不通的,因为当您向 TCP 连接发送0x04时,Linux 不会神奇地向任何进程发送SIGINT 。
问题2: top不起作用
当我尝试在这个 shell 中运行top时,我收到错误消息top: failed tty get 。如果我们 strace 它,我们会看到这个系统调用:
ioctl(2, TCGETS, 0x7ffec4e68d60) = -1 ENOTTY (Inappropriate ioctl for device)
因此, top在其输出文件描述符 (2) 上运行ioctl以获取有关终端的一些信息。但是 Linux 就像“嘿,这不是终端!”并返回错误。
还有很多其他的问题,但希望此时您确信我们确实需要将 bash 的 stdout/stderr 设置为终端,而不是像套接字这样的其他东西。
因此,让我们开始查看服务器代码,看看创建伪终端的实际情况。
第 1 步:创建一个伪终端
这是一些在 Linux 上创建伪终端的 Go 代码。这是从github.com/creack/pty复制的,但我删除了一些错误处理以使逻辑更容易理解:
pty, _ := os.OpenFile("/dev/ptmx", os.O_RDWR, 0) sname := ptsname(p) unlockpt(p) tty, _ := os.OpenFile(sname, os.O_RDWR|syscall.O_NOCTTY, 0)
在英语中,我们正在做的是:
- 打开
/dev/ptmx以获取“伪终端主机” 同样,这是我们要连接到 TCP 连接的部分 - 获取“从属伪终端设备”的文件名,即
/dev/pts/13之类的。 - “解锁”伪终端,以便我们可以使用它。我不知道这是什么意思(为什么一开始就被锁定?)但是出于某种原因你必须这样做
- 打开
/dev/pts/13(或我们从ptsname获得的任何数字)以获取“从伪终端设备”
这些ptsname和unlockpt函数有什么作用?他们只是对 Linux 内核进行一些ioctl系统调用。与 Linux 内核有关终端的所有通信似乎都是通过各种ioctl系统调用进行的。
这是代码,它很短:(再次,我只是从creack/pty复制它)
func ptsname(f *os.File) string { var n uint32 ioctl(f.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n))) return "/dev/pts/" + strconv.Itoa(int(n)) } func unlockpt(f *os.File) { var u int32 // use TIOCSPTLCK with a pointer to zero to clear the lock ioctl(f.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&u))) }
第 2 步:将伪终端连接到bash
接下来我们要做的是将伪终端连接到bash 。幸运的是,这真的很简单——这是它的 Go 代码!我们只需要启动一个新进程并将 stdin、stdout 和 stderr 设置为tty 。
cmd := exec.Command("bash") cmd.Stdin = tty cmd.Stdout = tty cmd.Stderr = tty cmd.SysProcAttr = &syscall.SysProcAttr{ Setsid: true, } cmd.Start()
简单的!虽然——为什么我们需要这个Setsid: true的东西,你可能会问?好吧,我试着注释掉那段代码,看看出了什么问题。事实证明,出了问题的是 – Ctrl + C 不再起作用了!
Setsid: true为新的 bash 进程创建一个新会话。但是为什么这会让Ctrl + C起作用呢?当您按下Ctrl + C时,Linux 如何知道将SIGINT发送到哪个进程,这与会话有什么关系?
Linux 如何知道将 Ctrl + C 发送到哪个进程?
我发现这很令人困惑,所以我找到了我最喜欢的书来学习这种东西: linux 编程接口,特别是第 34 章关于进程组和会话的内容。
该章包含几个关键事实:(#3、#4 和 #5 是书中的直接引述)
- 每个进程都有一个会话 ID和一个进程组 ID (可能与其 PID 相同也可能不同)
- 一个会话由多个进程组组成
- 会话中的所有进程共享一个控制终端。
- 一个终端最多可以是一个会话的控制终端。
- 在任何时间点,会话中的一个进程组是终端的前台进程组,其他进程组是后台进程组。
- 当您在终端中按
Ctrl+C时,SIGINT 会被发送到前台进程组中的所有进程
什么是进程组?嗯,我的理解是:
- 同一管道中的进程
x | y | z在同一个进程组中 - 您在同一 shell 行 (
x && y && z) 上启动的进程位于同一进程组中 - 默认情况下,子进程位于同一进程组中,除非您另有明确决定
我不知道其中的大部分内容(我不知道进程有会话 ID!)所以这有点需要吸收。我试着画出一幅粗略的 ASCII 艺术图
(maybe) terminal --- session --- process group --- process | |- process | |- process |- process group | |- process group
因此,当我们在终端中按 Ctrl+C 时,我认为会发生以下情况:
-
\x04被写入终端的“pseudotermimal master” - Linux 找到该终端的会话(如果存在)
- Linux 查找该会话的前台进程组
- Linux 发送
SIGINT
如果我们不为我们的新 bash 进程创建一个新会话,我们的新伪终端实际上不会有任何与之关联的会话,所以当我们按下Ctrl+C时不会发生任何事情。但是如果我们确实创建了一个新会话,那么新的伪终端将拥有与之关联的新会话。
如何获取所有会话的列表
顺便说一句,如果您想获取 Linux 机器上所有会话的列表,按会话分组,您可以运行:
$ ps -eo user,pid,pgid,sess,cmd | sort -k3
这包括 PID、进程组 ID 和会话 ID。作为输出示例,以下是管道中的两个进程:
bork 58080 58080 57922 ps -eo user,pid,pgid,sess,cmd bork 58081 58080 57922 sort -k3
您可以看到它们共享相同的进程组 ID 和会话 ID,但当然它们具有不同的 PID。
这有点多,但这就是我们在这篇文章中要说的关于会话和流程组的全部内容。我们继续吧!
第三步:设置窗口大小
我们需要告诉终端有多大!
同样,我只是从creack/pty复制了这个。我决定将大小硬编码为 80×24。
Setsize(tty, &Winsize{ Cols: 80, Rows: 24, })
就像获取终端的 pts 文件名并解锁它一样,设置大小只是一个ioctl系统调用:
func Setsize(t *os.File, ws *Winsize) { ioctl(t.Fd(), syscall.TIOCSWINSZ, uintptr(unsafe.Pointer(ws))) }
很简单!我们可以做一些更聪明的事情并获得真正的窗口大小,但我太懒了。
第四步:在 TCP 连接和伪终端之间复制信息
提醒一下,我们设置这个远程登录服务器的粗略步骤是:
- 创建一个伪终端供客户端使用
- 启动
bashshell 进程 - 将
bash连接到伪终端 - 在 TCP 连接和伪终端之间不断地来回复制信息
我们已经完成了 1、2 和 3,现在我们只需要在 TCP 连接和伪终端之间传递信息。
有两个io.Copy调用,一个用于从tcp 连接复制输入,一个用于将输出复制到TCP 连接。代码如下所示:
go func() { io.Copy(pty, conn) }() io.Copy(conn, pty)
第一个是在一个 goroutine 中,所以它们可以并行运行。
很简单!
第 5 步:完成后退出
我还添加了一些代码来在命令退出时关闭 TCP 连接
go func() { cmd.Wait() conn.Close() }()
服务器就是这样!你可以在这里看到所有的 Go 代码: server.go 。
下一步:写一个客户端
接下来,我们必须编写一个客户端。这比服务器要多得多,因为我们不需要做太多的终端设置。只有3个步骤:
- 将终端置于原始模式
- 将标准输入/标准输出复制到 TCP 连接
- 重置终端
客户端步骤 1:将终端置于“原始”模式
我们需要将客户端设置为“原始”模式,以便每次按下某个键时,它都会立即发送到 TCP 连接。如果我们不这样做,那么只有在您按 Enter 时才会发送所有内容。
“原始模式”实际上不是一个单一的东西,它是一堆你想要关闭的标志。有一个很好的教程解释了我们必须关闭的所有标志,称为进入原始模式。
与终端的其他一切一样,这需要ioctl系统调用。在这种情况下,我们获取终端的当前设置,修改它们,并保存旧设置,以便我们以后可以恢复它们。
我通过转到https://grep.app并输入syscall.TCSETS来查找其他一些做同样事情的 Go 代码,想出了如何在 Go 中执行此操作。
func MakeRaw(fd uintptr) syscall.Termios { // from https://github.com/getlantern/lantern/blob/devel/archive/src/golang.org/x/crypto/ssh/terminal/util.go var oldState syscall.Termios ioctl(fd, syscall.TCGETS, uintptr(unsafe.Pointer(&oldState))) newState := oldState newState.Iflag &^= syscall.ISTRIP | syscall.INLCR | syscall.ICRNL | syscall.IGNCR | syscall.IXON | syscall.IXOFF newState.Lflag &^= syscall.ECHO | syscall.ICANON | syscall.ISIG ioctl(fd, syscall.TCSETS, uintptr(unsafe.Pointer(&newState))) return oldState }
客户端步骤 2:将 stdin/stdout 复制到 TCP 连接
这与我们对服务器所做的完全一样。这是非常少的代码:
go func() { io.Copy(conn, os.Stdin) }() io.Copy(os.Stdout, conn)
客户端第三步:恢复终端状态
我们可以像这样将终端恢复到它开始的模式(另一个ioctl !):
func Restore(fd uintptr, oldState syscall.Termios) { ioctl(fd, syscall.TCSETS, uintptr(unsafe.Pointer(&oldState))) }
我们做到了!
我们编写了一个小型远程登录服务器,任何人都可以登录!万岁!
显然,这具有零安全性,所以我不打算谈论这方面。
它在公共互联网上运行!你可以试试看!
在接下来的一周左右,我将在互联网上的tetris.jvns.ca上运行一个演示。它运行俄罗斯方块而不是 shell,因为我想避免滥用,但如果你想用 shell 尝试它,你可以在你自己的计算机上运行它:)。
如果您想尝试一下,可以使用netcat作为客户端,而不是我们编写的自定义 Go 客户端程序,因为 netcat 所做的就是将信息复制到 TCP 连接或从 TCP 连接复制信息。就是这样:
stty raw -echo && nc tetris.jvns.ca 7777 && stty sane
这将让您玩一个名为tint的终端俄罗斯方块游戏。
您还可以使用client.go 程序并运行go run client.go tetris.jvns.ca 7777 。
这不是一个好的协议
这个协议我们只是将字节从 TCP 连接复制到终端而没有其他东西是不好的,因为它不允许我们发送信息信息,如终端或终端的实际窗口大小。
我想过实现 telnet 的协议,以便我们可以使用 telnet 作为客户端,但我不想弄清楚 telnet 是如何工作的,所以我没有。 (服务器 30% 可以按原样使用 telnet,但是很多东西都坏了,我不太清楚为什么,我也不想弄清楚)
它会让你的终端有点混乱
作为一个警告:使用这个服务器来玩俄罗斯方块可能会让你的终端有点混乱,因为它将窗口大小设置为 80×24。为了解决这个问题,我在运行该命令后关闭了终端选项卡。
如果我们想真正解决这个问题,我们需要在完成后恢复窗口大小,但是我们需要一个比“只是盲目地用 TCP 来回复制字节”稍微更真实的协议,而我没有不想那样做。
此外,由于某种原因,程序退出后有时需要一秒钟才能断开连接,我不确定这是为什么。
其他小项目
就这样!我在这里编写的程序还有其他一些类似的玩具实现:
原文: https://jvns.ca/blog/2022/07/28/toy-remote-login-server/