用Go测试SSH客户端

3
我用Go编写了一个SSH客户端,我想编写一些测试。问题在于,我从来没有真正地编写过适当的单元测试,大多数教程似乎都集中在为添加两个数字或其他玩具问题编写测试函数。我已经了解了有关模拟、使用接口和其他技术的知识,但我在应用它们时遇到了困难。此外,我的客户端将同时被使用,以允许对多个设备进行快速配置。不确定这是否会改变我编写测试的方式或是否需要添加额外的测试。感谢任何帮助。
以下是我的代码。基本上,一个设备有4个主要功能:Connect连接设备,Send发送配置命令,Output/Err捕获会话的输出,和Close关闭客户端。
package device

import (
    "bufio"
    "fmt"
    "golang.org/x/crypto/ssh"
    "io"
    "net"
    "time"
)

// A Device represents a remote network device.
type Device struct {
    Host    string         // the device's hostname or IP address
    client  *ssh.Client    // the client connection
    session *ssh.Session   // the connection to the remote shell
    stdin   io.WriteCloser // the remote shell's standard input
    stdout  io.Reader      // the remote shell's standard output
    stderr  io.Reader      // the remote shell's standard error
}

// Connect establishes an SSH connection to a device and sets up the session IO.
func (d *Device) Connect(user, password string) error {
    // Create a client connection
    client, err := ssh.Dial("tcp", net.JoinHostPort(d.Host, "22"), configureClient(user, password))
    if err != nil {
        return err
    }
    d.client = client
    // Create a session
    session, err := client.NewSession()
    if err != nil {
        return err
    }
    d.session = session
    return nil
}

// configureClient sets up the client configuration for login
func configureClient(user, password string) *ssh.ClientConfig {
    var sshConfig ssh.Config
    sshConfig.SetDefaults()
    sshConfig.Ciphers = append(sshConfig.Ciphers, "aes128-cbc", "aes256-cbc", "3des-cbc", "des-cbc", "aes192-cbc")
    config := &ssh.ClientConfig{
        Config:          sshConfig,
        User:            user,
        Auth:            []ssh.AuthMethod{ssh.Password(password)},
        HostKeyCallback: ssh.InsecureIgnoreHostKey(),
        Timeout:         time.Second * 5,
    }
    return config
}

// setupIO creates the pipes connected to the remote shell's standard input, output, and error
func (d *Device) setupIO() error {
    // Setup standard input pipe
    stdin, err := d.session.StdinPipe()
    if err != nil {
        return err
    }
    d.stdin = stdin
    // Setup standard output pipe
    stdout, err := d.session.StdoutPipe()
    if err != nil {
        return err
    }
    d.stdout = stdout
    // Setup standard error pipe
    stderr, err := d.session.StderrPipe()
    if err != nil {
        return err
    }
    d.stderr = stderr
    return nil
}

// Send sends cmd(s) to the device's standard input. A device only accepts one call
// to Send, as it closes the session and its standard input pipe.
func (d *Device) Send(cmds ...string) error {
    if d.session == nil {
        return fmt.Errorf("device: session is closed")
    }
    defer d.session.Close()
    // Start the shell
    if err := d.startShell(); err != nil {
        return err
    }
    // Send commands
    for _, cmd := range cmds {
        if _, err := d.stdin.Write([]byte(cmd + "\r")); err != nil {
            return err
        }
    }
    defer d.stdin.Close()
    // Wait for the commands to exit
    d.session.Wait()
    return nil
}

// startShell requests a pseudo terminal (VT100) and starts the remote shell.
func (d *Device) startShell() error {
    modes := ssh.TerminalModes{
        ssh.ECHO:          0, // disable echoing
        ssh.OCRNL:         0,
        ssh.TTY_OP_ISPEED: 14400,
        ssh.TTY_OP_OSPEED: 14400,
    }
    err := d.session.RequestPty("vt100", 0, 0, modes)
    if err != nil {
        return err
    }
    if err := d.session.Shell(); err != nil {
        return err
    }
    return nil
}

// Output returns the remote device's standard output output.
func (d *Device) Output() ([]string, error) {
    return readPipe(d.stdout)
}

// Err returns the remote device's standard error output.
func (d *Device) Err() ([]string, error) {
    return readPipe(d.stdout)
}

// reapPipe reads an io.Reader line by line
func readPipe(r io.Reader) ([]string, error) {
    var lines []string
    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        lines = append(lines, scanner.Text())
    }
    if err := scanner.Err(); err != nil {
        return nil, err
    }
    return lines, nil
}

// Close closes the client connection.
func (d *Device) Close() error {
    return d.client.Close()
}

// String returns the string representation of a `Device`.
func (d *Device) String() string {
    return fmt.Sprintf("%s", d.Host)
}

4
如果你想在公司网络中配置生产设备,我真的建议你不要使用自己编写的程序来完成。很可能会出现 bug,造成的损失可能非常严重。请使用一些配置管理工具,例如 ansible(https://www.ansible.com/),来配置你的设备。这些工具将通过 ssh 连接并进行配置。这些工具已经准备好用于生产环境,并已经使用多年。 - mbuechmann
1
@mbuechmann:好建议,但与问题无关(就像整个序言与问题无关一样)。 - Jonathan Hall
@mbuechmann 感谢您的建议。出于好奇,像Ansible这样的东西和我的程序有什么区别(一旦我的程序经过彻底测试并被证明是稳定的)?除了Flimzy所说的之外,我仍然想学习如何使我的程序尽可能健壮,并继续为个人使用/练习而努力工作。 - mwalto7
1
要想与现有解决方案实现功能平衡,需要耗费许多人年的开发时间。这不是你想用个人项目尝试的事情。如果你的公司真的想花钱做这件事,我想你可以去做,但作为一名实际的管理员,我可能不希望在生产环境中使用这个东西,除非经过几年的开发... - Michael Hampton
1个回答

3
你提出了一个很好的观点,即单元测试教程几乎总是针对玩具问题(为什么总是斐波那契数列?),而我们实际面对的是数据库和HTTP服务器。对我有帮助的重要认识是,只有在可以控制单元输入和输出的情况下才能进行单元测试。 configureClientreadPipe(给它一个 strings.Reader)将是很好的选择。从这里开始吧。
任何通过直接与磁盘、网络、stdout等交互方式离开程序的东西,比如你认为是你程序外部接口的 Connect 方法。你不用对它们进行单元测试,而是使用集成测试。
Device 更改为接口而不是结构体,并创建实现它的 MockDevice。现在真正的设备可能是 SSHDevice。通过插入 MockDevice 来隔离自己与网络的联系,可以对使用 Device 接口的程序的其余部分进行单元测试。 SSHDevice 将在你的集成测试中进行测试。启动一个真实的 SSH 服务器(也许是你使用 crypto/ssh 包编写的 Go 测试服务器,但任何 sshd 都可以)。使用一个 SSHDevice 启动你的程序,让它们相互通信,并检查输出。你将会经常使用 os/exec 包。集成测试比单元测试更有趣!

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接