You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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一致:

  1. 初始化PTY时获取当前终端尺寸
  2. 监听窗口resize事件,同步尺寸到远程会话
  3. 正确绑定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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 02:00:57