Go实现SSH会话日志:PTY双重回显难题如何破?

Go 实现 SSH 会话日志:解决 PTY 双重回显的难题

一、开篇:恼人的双重回显

用 Go 写一个简单的堡垒机或 SSH 操作审计工具时,一个核心需求就是记录整个会话的输入和输出。很多人会自然地想到使用 os/exec 启动 ssh 进程,并结合 creack/pty 这类库来创建一个伪终端(PTY),然后将用户的标准输入输出(Stdin/Stdout)与 PTY 进行双向绑定。

思路很清晰,代码写出来也看似简单:

func connect(user, host string, logger io.Writer) error {
    cmd := exec.Command("ssh", user+"@"+host)
    t, err := pty.Start(cmd)
    if err != nil {
        return err
    }
    defer t.Close()

    // 将 PTY 的输出转发到标准输出和日志记录器
    go func() {
        // 使用 MultiWriter 可以同时写入多个目的地
        mw := io.MultiWriter(os.Stdout, logger)
        io.Copy(mw, t)
    }()

    // 将标准输入转发到 PTY
    go func() {
        io.Copy(t, os.Stdin)
    }()

    return cmd.Wait()
}

然而,程序一跑起来,问题立刻就出现了。当你在终端里尝试输入命令,比如 ls,屏幕上会显示出 llss。每个字符都被重复了一次。这种双重回显(Double Echo)不仅看起来非常别扭,也让终端操作变得几乎不可能。

二、为什么会这样?剖析 PTY 回显机制

要解决这个问题,得先搞明白为什么会多出来一次回显。这得从终端的工作模式说起。

当你的 Go 程序运行时,它所在的那个终端(比如你的 iTerm2 或者 Bash Shell)默认处于“规范模式”(Canonical Mode,也叫 Cooked Mode)。在这种模式下,终端本身会负责一些基础的行编辑功能,其中就包括“本地回显”——你敲下什么键,它就立刻在屏幕上显示什么。

现在我们梳理一下整个数据流:

  1. 用户输入 :你在键盘上敲下字符 l
  2. 本地终端回显 :你的本地终端接收到 l,立即在屏幕上显示 l。这是第一次回显
  3. Go 程序读取os.Stdin 读到了字符 l
  4. 写入 PTYio.Copy(t, os.Stdin)l 写入了 PTY 的主设备端(master)。
  5. SSH 进程接收 :PTY 的从设备端(slave)连接着 ssh 进程,ssh 进程收到了 l
  6. 远端服务器回显ssh 进程将 l 发送到远端服务器,远端的 Shell(比如 Bash)处理后,会把字符 l 再发送回来,作为输入确认。这是 Shell 的标准行为。
  7. PTY 接收回显ssh 进程将从远端收到的回显 l 写回 PTY 从设备端。
  8. Go 程序读取io.Copy(mw, t) 从 PTY 主设备端读到了这个从远端返回的 l
  9. 显示在屏幕io.MultiWriter 将这个 l 写入 os.Stdout,于是屏幕上又出现了一个 l。这是第二次回显

问题就出在这里:本地终端和远端 Shell 都进行了回显。我们需要做的,就是干掉其中一个。

三、解决方案:对症下药

最直接也最正确的做法,是禁用本地终端的回显,让远端 Shell 全权负责。

方案一:釜底抽薪——设置终端为 Raw 模式 (推荐)

原理与作用

我们可以命令本地终端:“别自作主张了,进入 Raw 模式吧!”。在 Raw 模式下,终端停止了大部分的预处理,包括本地回显、行缓冲、以及对特殊控制字符(如 Ctrl+C)的解释。所有按键都会被原封不动地传递给应用程序。

这样一来,数据流就变得清晰了:

  1. 用户敲下 l
  2. 本地终端(Raw 模式)不回显,直接把 los.Stdin
  3. 后续流程不变,最终只有远端 Shell 的回显被我们的 Go 程序打印到屏幕上。

问题完美解决。而且这种方式还有一个巨大的好处:它能正确处理密码输入。当远端需要输入密码时,它会临时关闭回显,由于我们依赖的是远端回显,所以密码也就不会显示在屏幕上,符合预期。

操作步骤与代码

Go 官方的扩展库 golang.org/x/term 提供了我们需要的所有工具。

import (
    "fmt"
    "io"
    "log"
    "os"
    "os/exec"
    "os/signal"
    "syscall"

    "github.com/creack/pty"
    "golang.org/x/term"
)

func connect(user, host string) error {
    cmd := exec.Command("ssh", user+"@"+host)

    // 将当前终端置于 Raw 模式
    // os.Stdin.Fd() 返回标准输入的文件符,是一个 uintptr 类型,需要转为 int
    oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
    if err != nil {
        return fmt.Errorf("failed to set raw mode: %w", err)
    }
    // !!! 关键:无论程序如何退出,都必须恢复终端的原始状态
    defer term.Restore(int(os.Stdin.Fd()), oldState)

    // 启动 PTY
    ptmx, err := pty.Start(cmd)
    if err != nil {
        return fmt.Errorf("failed to start pty: %w", err)
    }
    defer ptmx.Close()

    // 处理窗口大小变化
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, syscall.SIGWINCH)
    go func() {
        for range ch {
            if err := pty.InheritSize(os.Stdin, ptmx); err != nil {
                log.Printf("error resizing pty: %s", err)
            }
        }
    }()
    ch <- syscall.SIGWINCH // 初始时触发一次,设置初始窗口大小

    // 双向数据拷贝
    go func() { io.Copy(ptmx, os.Stdin) }()
    go func() { io.Copy(os.Stdout, ptmx) }() // 这里可以替换为 MultiWriter 来记录日志

    return cmd.Wait()
}

安全建议与进阶技巧

  1. 务必恢复终端状态 :代码中的 defer term.Restore(...) 至关重要。如果程序异常崩溃而没有执行这句,你的终端会卡在 Raw 模式,表现为不回显、命令错乱,只能关闭重开。
  2. 同步窗口大小 :上面的代码包含了一个进阶技巧。远端的程序(如 vim, htop)需要知道终端的尺寸才能正确绘制界面。我们通过监听 SIGWINCH 信号(窗口大小改变信号)并使用 pty.InheritSize 来动态同步窗口尺寸,提升了可用性。

方案二:曲线救国——过滤输出流 (不推荐)

有人可能会想,既然输入和输出里都有重复字符,我能不能在程序里写个逻辑,把输入缓存起来,当输出流里出现跟输入一样的字符时,就把它过滤掉?

这个想法很直观,但实践起来是个噩梦,强烈不推荐。

  • 逻辑脆弱 :如果一个命令的正常输出内容恰好和你的输入一样呢?比如 echo "ls",输出的 ls 就会被错误地过滤掉。
  • 无法处理控制字符 :退格键、方向键、Tab 补全等都会产生复杂的 ANSI 转义序列,过滤逻辑会变得异常复杂且极易出错。
  • 存在竞态条件 :你无法保证远端的回显会紧随你的输入之后立即到达。网络延迟会让过滤逻辑彻底失效。

这是一个典型的看似聪明实则引入更多问题的反模式。

四、日志记录的安全考量

既然目标是会话审计,那么日志的安全性就是重中之重。

  • 敏感信息 :会话日志会记录所有操作,包括输入的密码、API 密钥、粘贴的敏感数据等。
  • 权限控制 :存储日志的文件或数据库必须有严格的访问控制。在文件系统上,权限应设置为 600,仅拥有者可读写。
  • 加密存储 :对日志内容进行加密存储(静态加密),防止数据泄露后被直接读取。
  • 合规与告知 :在生产环境使用,必须确保符合相关法律法规,并明确告知用户他们的会话将被记录。