Go实现SSH客户端时全屏终端编辑器显示异常及调试咨询
SSH客户端全屏编辑器异常问题解决与调试指南
问题背景
通过Go语言实现SSH客户端,大部分功能正常,但使用nano等全屏终端编辑器时出现屏幕底部内容截断/重叠、行重复、光标移动异常等问题。需求也可考虑TypeScript/JS方案,同时疑惑全屏编辑器(本质为转义序列)为何会比终端着色功能更容易出问题,并询问转义序列的调试方法。
现有Go代码如下:
func Connect() { // SSH server details sshHost := "192.168.1.166" sshPort := 22 sshUser := "user" sshPassword := "pass" fmt.Printf("Connecting to %s@%s:%d...\n", sshUser, sshHost, sshPort) // Create SSH configuration sshConfig := &ssh.ClientConfig{ User: sshUser, Auth: []ssh.AuthMethod{ ssh.Password(sshPassword), }, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } // Establish SSH connection sshAddress := fmt.Sprintf("%s:%d", sshHost, sshPort) sshClient, err := ssh.Dial("tcp", sshAddress, sshConfig) if err != nil { fmt.Println("Failed to establish SSH connection:", err) os.Exit(1) } defer sshClient.Close() // Request a pseudo-terminal session, err := sshClient.NewSession() if err != nil { fmt.Println("Failed to create session:", err) os.Exit(1) } defer session.Close() // Start a pseudo-terminal session err = session.RequestPty("xterm-256color", 80, 40, ssh.TerminalModes{ ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400, }) if err != nil { fmt.Println("Failed to request pseudo-terminal:", err) os.Exit(1) } // !IO STREAMS // Set up stdin, stdout, and stderr session.Stderr = os.Stderr stdin, err := session.StdinPipe() if err != nil { fmt.Println("Failed to set up stdin:", err) os.Exit(1) } defer stdin.Close() stdout, err := session.StdoutPipe() if err != nil { fmt.Println("Failed to set up stdout:", err) os.Exit(1) } go func() { if _, err := io.Copy(os.Stdout, stdout); err != nil { fmt.Println("Failed to copy stdout:", err) os.Exit(1) } }() // Start a shell err = session.Shell() if err != nil { fmt.Println("Failed to start shell:", err) os.Exit(1) } // Set terminal size for the remote shell fd := int(os.Stdin.Fd()) state, err := term.MakeRaw(fd) if err != nil { fmt.Println("Error setting terminal raw mode: ", err) return } defer term.Restore(fd, state) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh) // Wait for interrupt signal go func() { <-sigCh fmt.Println("Received interrupt signal. Cleaning up...") term.Restore(fd, state) session.Close() sshClient.Close() os.Exit(0) }() // Wait for the session to finish err = session.Wait() if err != nil { fmt.Println("Session failed:", err) os.Exit(1) } }
一、Go代码修复方案
核心问题是终端尺寸未同步:硬编码了固定尺寸,未监听本地窗口变化并同步到远程SSH会话,同时stdin流未正确绑定。
1. 获取并使用实际终端初始尺寸
替换硬编码的80,40,先获取当前终端的真实大小:
// 获取当前终端尺寸 fd := int(os.Stdin.Fd()) size, err := term.GetSize(fd) if err != nil { fmt.Println("Failed to get terminal size:", err) os.Exit(1) } // 请求PTY时使用实际尺寸 err = session.RequestPty("xterm-256color", size.Height, size.Width, ssh.TerminalModes{ ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400, })
2. 监听窗口大小变化并同步到远程会话
添加SIGWINCH信号监听,实时更新远程终端尺寸:
// 监听窗口大小变化信号 winchCh := make(chan os.Signal, 1) signal.Notify(winchCh, syscall.SIGWINCH) go func() { for range winchCh { if newSize, err := term.GetSize(fd); err == nil { if err := session.WindowChange(newSize.Height, newSize.Width); err != nil { fmt.Printf("Failed to update remote window size: %v\n", err) } } } }()
3. 正确绑定stdin流
添加本地stdin到远程会话的复制逻辑:
// 复制本地输入到远程会话 go func() { if _, err := io.Copy(stdin, os.Stdin); err != nil { fmt.Println("Failed to copy stdin to remote session:", err) } }()
修正后的完整代码
import ( "fmt" "io" "os" "os/signal" "syscall" "golang.org/x/crypto/ssh" "golang.org/x/term" ) func Connect() { // SSH server details sshHost := "192.168.1.166" sshPort := 22 sshUser := "user" sshPassword := "pass" fmt.Printf("Connecting to %s@%s:%d...\n", sshUser, sshHost, sshPort) // Create SSH configuration sshConfig := &ssh.ClientConfig{ User: sshUser, Auth: []ssh.AuthMethod{ ssh.Password(sshPassword), }, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } // Establish SSH connection sshAddress := fmt.Sprintf("%s:%d", sshHost, sshPort) sshClient, err := ssh.Dial("tcp", sshAddress, sshConfig) if err != nil { fmt.Println("Failed to establish SSH connection:", err) os.Exit(1) } defer sshClient.Close() // Request a pseudo-terminal session, err := sshClient.NewSession() if err != nil { fmt.Println("Failed to create session:", err) os.Exit(1) } defer session.Close() // 获取当前终端尺寸 fd := int(os.Stdin.Fd()) size, err := term.GetSize(fd) if err != nil { fmt.Println("Failed to get terminal size:", err) os.Exit(1) } // Start a pseudo-terminal session err = session.RequestPty("xterm-256color", size.Height, size.Width, ssh.TerminalModes{ ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400, }) if err != nil { fmt.Println("Failed to request pseudo-terminal:", err) os.Exit(1) } // Set up stdin, stdout, and stderr session.Stderr = os.Stderr stdin, err := session.StdinPipe() if err != nil { fmt.Println("Failed to set up stdin:", err) os.Exit(1) } defer stdin.Close() stdout, err := session.StdoutPipe() if err != nil { fmt.Println("Failed to set up stdout:", err) os.Exit(1) } go func() { if _, err := io.Copy(os.Stdout, stdout); err != nil { fmt.Println("Failed to copy stdout:", err) } }() // 复制本地输入到远程会话 go func() { if _, err := io.Copy(stdin, os.Stdin); err != nil { fmt.Println("Failed to copy stdin to remote session:", err) } }() // Start a shell err = session.Shell() if err != nil { fmt.Println("Failed to start shell:", err) os.Exit(1) } // Set terminal raw mode state, err := term.MakeRaw(fd) if err != nil { fmt.Println("Error setting terminal raw mode: ", err) return } defer term.Restore(fd, state) // Handle signals sigCh := make(chan os.Signal, 2) signal.Notify(sigCh, os.Interrupt, syscall.SIGWINCH) // Signal handler go func() { for sig := range sigCh { switch sig { case os.Interrupt: fmt.Println("\nReceived interrupt signal. Cleaning up...") term.Restore(fd, state) session.Close() sshClient.Close() os.Exit(0) case syscall.SIGWINCH: if newSize, err := term.GetSize(fd); err == nil { if err := session.WindowChange(newSize.Height, newSize.Width); err != nil { fmt.Printf("Failed to update remote window size: %v\n", err) } } } } }() // Wait for the session to finish err = session.Wait() if err != nil { fmt.Println("Session failed:", err) os.Exit(1) } }
二、全屏编辑器异常原因
终端着色仅需简单的转义序列修改文本样式,不依赖终端几何尺寸;但全屏编辑器(如nano、vim)需要精确的终端行/列数来计算界面布局:
- 编辑器启动时会查询SSH会话的终端尺寸,以此确定可显示的内容范围
- 如果远程会话尺寸与本地终端实际尺寸不匹配,编辑器会错误渲染界面,导致底部内容截断、光标位置错乱
- 若未监听窗口大小变化,用户调整本地终端窗口后,远程编辑器仍使用旧尺寸,进一步加剧异常
三、转义序列调试方法
- 日志记录转义序列:拦截并转义不可见字符,将转义序列转为可读形式输出
示例代码:import "strings" import "bufio" go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() // 将ESC字符转为[ESC]便于查看 readableLine := strings.ReplaceAll(line, "\033", "[ESC]") fmt.Printf("[DEBUG] %s\n", readableLine) // 同时输出到终端 fmt.Fprintln(os.Stdout, line) } }() - 使用系统工具录制会话:用
script命令录制正常SSH会话和你的程序会话,再用cat -v查看录制文件,转义序列会以^[(代表ESC)等可见形式显示,便于对比差异 - 终端模拟器调试模式:如xterm的
-trace选项,可记录所有发送到终端的转义序列,用于对比程序输出 - 直接对比流内容:将正常SSH会话和你的程序的stdout内容保存到文件,逐行对比转义序列差异
四、TypeScript/JS方案思路
使用Node.js的ssh2库实现,核心逻辑与Go一致:
- 初始化PTY时获取当前终端尺寸
- 监听窗口resize事件,同步尺寸到远程会话
- 正确绑定stdin/stdout/stderr流
示例代码:
const { Client } = require('ssh2'); const readline = require('readline'); // 启用终端原始模式,避免readline干扰 readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); const conn = new Client(); conn.on('ready', () => { conn.shell({ term: 'xterm-256color', rows: process.stdout.rows, cols: process.stdout.columns }, (err, stream) => { if (err) { console.error('Failed to create shell:', err); conn.end(); process.exit(1); } // 同步窗口大小变化 process.stdout.on('resize', () => { stream.setWindowSize(process.stdout.columns, process.stdout.rows); }); // 绑定数据流 stream.pipe(process.stdout); process.stdin.pipe(stream); // 处理会话关闭 stream.on('close', () => { conn.end(); process.exit(0); }); // 处理输入中断 process.stdin.on('keypress', (_, key) => { if (key.ctrl && key.name === 'c') { console.log('\nReceived interrupt, exiting...'); stream.end(); } }); }); }).connect({ host: '192.168.1.166', port: 22, username: 'user', password: 'pass' }); // 连接错误处理 conn.on('error', (err) => { console.error('Connection error:', err); process.exit(1); });
内容的提问来源于stack exchange,提问作者Denshin
相关产品推荐
相关产品推荐

