Skip to content

搞英语 → 看世界

翻译英文优质信息和名人推特

Menu
  • 首页
  • 作者列表
  • 独立博客
  • 专业媒体
  • 名人推特
  • 邮件列表
  • 关于本站
Menu

一个玩具远程登录服务器

Posted on 2022-07-29

你好!前几天我们讨论了当你按下终端中的一个键时发生的事情。

作为后续,我认为实现一个类似于小型 ssh 服务器但没有安全性的程序可能会很有趣。你可以在 github 上找到它,我将在这篇博文中解释它是如何工作的。

目标:“ssh”到远程计算机

我们的目标是能够登录到远程计算机并运行命令,就像使用 SSH 或 telnet 一样。

该程序与 SSH 之间的最大区别在于实际上没有安全性(甚至没有密码)——任何可以与服务器建立 TCP 连接的人都可以获得 shell 并运行命令。

显然这在现实生活中不是一个有用的程序,但我们的目标是更多地了解终端的工作原理,而不是编写一个有用的程序。

(不过,我将在下周在公共互联网上运行它的一个版本,你可以在这篇博文的末尾看到如何连接它)

让我们从服务器开始吧!

我们还将编写一个客户端,但服务器是有趣的部分,所以让我们从那里开始。我们将编写一个侦听 TCP 端口(我选择 7777)的服务器,并为连接到它的任何客户端创建远程终端以供使用。

当服务器接收到新连接时,它需要:

  1. 创建一个伪终端供客户端使用
  2. 启动一个bash shell 进程供客户端使用
  3. 将bash连接到伪终端
  4. 在 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 是书中的直接引述)

  1. 每个进程都有一个会话 ID和一个进程组 ID (可能与其 PID 相同也可能不同)
  2. 一个会话由多个进程组组成
  3. 会话中的所有进程共享一个控制终端。
  4. 一个终端最多可以是一个会话的控制终端。
  5. 在任何时间点,会话中的一个进程组是终端的前台进程组,其他进程组是后台进程组。
  6. 当您在终端中按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 连接和伪终端之间复制信息

提醒一下,我们设置这个远程登录服务器的粗略步骤是:

  1. 创建一个伪终端供客户端使用
  2. 启动bash shell 进程
  3. 将bash连接到伪终端
  4. 在 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个步骤:

  1. 将终端置于原始模式
  2. 将标准输入/标准输出复制到 TCP 连接
  3. 重置终端

客户端步骤 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 来回复制字节”稍微更真实的协议,而我没有不想那样做。

此外,由于某种原因,程序退出后有时需要一秒钟才能断开连接,我不确定这是为什么。

其他小项目

就这样!我在这里编写的程序还有其他一些类似的玩具实现:

  • 玩具 TLS 1.3 实现
  • 玩具 DNS 解析器

原文: https://jvns.ca/blog/2022/07/28/toy-remote-login-server/

本站文章系自动翻译,站长会周期检查,如果有不当内容,请点此留言,非常感谢。
  • Abhinav
  • Abigail Pain
  • Adam Fortuna
  • Alberto Gallego
  • Alex Wlchan
  • Alin Panaitiu
  • Anil Dash
  • Answer.AI
  • Arne Bahlo
  • Ben Carlson
  • Ben Kuhn
  • Bert Hubert
  • Big Technology
  • Bits about Money
  • Brandon Skerritt
  • Brian Krebs
  • ByteByteGo
  • Chip Huyen
  • Chips and Cheese
  • Christopher Butler
  • Colin Percival
  • Cool Infographics
  • Dan Sinker
  • David Walsh
  • Dmitry Dolzhenko
  • Dustin Curtis
  • eighty twenty
  • Elad Gil
  • Ellie Huxtable
  • Ethan Dalool
  • Ethan Marcotte
  • Exponential View
  • FAIL Blog
  • Founder Weekly
  • Geoffrey Huntley
  • Geoffrey Litt
  • Greg Mankiw
  • HeardThat Blog
  • Henrique Dias
  • Herman Martinus
  • Hypercritical
  • IEEE Spectrum
  • Investment Talk
  • Jaz
  • Jeff Geerling
  • Jonas Hietala
  • Josh Comeau
  • Lenny Rachitsky
  • Li Haoyi
  • Liz Danzico
  • Lou Plummer
  • Luke Wroblewski
  • Maggie Appleton
  • Matt Baer
  • Matt Stoller
  • Matthias Endler
  • Mert Bulan
  • Mind Matters
  • Mostly metrics
  • Naval Ravikant
  • News Letter
  • NextDraft
  • Non_Interactive
  • Not Boring
  • One Useful Thing
  • Phil Eaton
  • PostHog
  • Product Market Fit
  • Readwise
  • ReedyBear
  • Robert Heaton
  • Rohit Patel
  • Ruben Schade
  • Sage Economics
  • Sam Altman
  • Sam Rose
  • selfh.st
  • Shtetl-Optimized
  • Simon schreibt
  • Slashdot
  • Slava Akhmechet
  • Small Good Things
  • Steph Ango
  • Stephen Wolfram
  • Steve Blank
  • Taylor Troesh
  • Telegram Blog
  • The Macro Compass
  • The Pomp Letter
  • thesephist
  • Thinking Deep & Wide
  • Tim Kellogg
  • Understanding AI
  • Wes Kao
  • 英文媒体
  • 英文推特
  • 英文独立博客
©2026 搞英语 → 看世界 | Design: Newspaperly WordPress Theme