Go实现SSH会话日志:PTY双重回显难题如何破?
- 系统运维
- 13小时前
- 13热度
- 0评论
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)。在这种模式下,终端本身会负责一些基础的行编辑功能,其中就包括“本地回显”——你敲下什么键,它就立刻在屏幕上显示什么。
现在我们梳理一下整个数据流:
- 用户输入 :你在键盘上敲下字符
l
。 - 本地终端回显 :你的本地终端接收到
l
,立即在屏幕上显示l
。这是第一次回显 。 - Go 程序读取 :
os.Stdin
读到了字符l
。 - 写入 PTY :
io.Copy(t, os.Stdin)
把l
写入了 PTY 的主设备端(master)。 - SSH 进程接收 :PTY 的从设备端(slave)连接着
ssh
进程,ssh
进程收到了l
。 - 远端服务器回显 :
ssh
进程将l
发送到远端服务器,远端的 Shell(比如 Bash)处理后,会把字符l
再发送回来,作为输入确认。这是 Shell 的标准行为。 - PTY 接收回显 :
ssh
进程将从远端收到的回显l
写回 PTY 从设备端。 - Go 程序读取 :
io.Copy(mw, t)
从 PTY 主设备端读到了这个从远端返回的l
。 - 显示在屏幕 :
io.MultiWriter
将这个l
写入os.Stdout
,于是屏幕上又出现了一个l
。这是第二次回显 。
问题就出在这里:本地终端和远端 Shell 都进行了回显。我们需要做的,就是干掉其中一个。
三、解决方案:对症下药
最直接也最正确的做法,是禁用本地终端的回显,让远端 Shell 全权负责。
方案一:釜底抽薪——设置终端为 Raw 模式 (推荐)
原理与作用
我们可以命令本地终端:“别自作主张了,进入 Raw 模式吧!”。在 Raw 模式下,终端停止了大部分的预处理,包括本地回显、行缓冲、以及对特殊控制字符(如 Ctrl+C
)的解释。所有按键都会被原封不动地传递给应用程序。
这样一来,数据流就变得清晰了:
- 用户敲下
l
。 - 本地终端(Raw 模式)不回显,直接把
l
给os.Stdin
。 - 后续流程不变,最终只有远端 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()
}
安全建议与进阶技巧
- 务必恢复终端状态 :代码中的
defer term.Restore(...)
至关重要。如果程序异常崩溃而没有执行这句,你的终端会卡在 Raw 模式,表现为不回显、命令错乱,只能关闭重开。 - 同步窗口大小 :上面的代码包含了一个进阶技巧。远端的程序(如
vim
,htop
)需要知道终端的尺寸才能正确绘制界面。我们通过监听SIGWINCH
信号(窗口大小改变信号)并使用pty.InheritSize
来动态同步窗口尺寸,提升了可用性。
方案二:曲线救国——过滤输出流 (不推荐)
有人可能会想,既然输入和输出里都有重复字符,我能不能在程序里写个逻辑,把输入缓存起来,当输出流里出现跟输入一样的字符时,就把它过滤掉?
这个想法很直观,但实践起来是个噩梦,强烈不推荐。
- 逻辑脆弱 :如果一个命令的正常输出内容恰好和你的输入一样呢?比如
echo "ls"
,输出的ls
就会被错误地过滤掉。 - 无法处理控制字符 :退格键、方向键、Tab 补全等都会产生复杂的 ANSI 转义序列,过滤逻辑会变得异常复杂且极易出错。
- 存在竞态条件 :你无法保证远端的回显会紧随你的输入之后立即到达。网络延迟会让过滤逻辑彻底失效。
这是一个典型的看似聪明实则引入更多问题的反模式。
四、日志记录的安全考量
既然目标是会话审计,那么日志的安全性就是重中之重。
- 敏感信息 :会话日志会记录所有操作,包括输入的密码、API 密钥、粘贴的敏感数据等。
- 权限控制 :存储日志的文件或数据库必须有严格的访问控制。在文件系统上,权限应设置为
600
,仅拥有者可读写。 - 加密存储 :对日志内容进行加密存储(静态加密),防止数据泄露后被直接读取。
- 合规与告知 :在生产环境使用,必须确保符合相关法律法规,并明确告知用户他们的会话将被记录。