Go实现SSH-Client客户端(2)
上一篇我们实现了一个SSH-Server的服务,这篇我们实现SSH-Client。SSH的客户端常见有交互式 tty-bash 和命令执行,其中命令执行又分为交互式 command 和一般的 bash 命令,如tail、top等属于交互式命令,而 ls、who 等属于一般的 bash 执行。
1、交互方式
package main
import (
    "log"
    "os"
    "golang.org/x/crypto/ssh"
    "golang.org/x/crypto/ssh/terminal"
)
func main() {
    config := &ssh.ClientConfig{
        User: "demo",
        Auth: []ssh.AuthMethod{ssh.Password("123456")},
    }
    client, err := ssh.Dial("tcp", "127.0.0.1:19022", config)
    if err != nil {
        log.Fatal(err)
    }
    session, err := client.NewSession()
    if err != nil {
        log.Fatal(err)
    }
    defer session.Close()
    session.Stdout = os.Stdout
    session.Stderr = os.Stderr
    session.Stdin = os.Stdin
    modes := ssh.TerminalModes{
        ssh.ECHO:          1,
        ssh.ECHOCTL:       0,
        ssh.TTY_OP_ISPEED: 14400,
        ssh.TTY_OP_OSPEED: 14400,
    }
    termFD := int(os.Stdin.Fd())
    w, h, _ := terminal.GetSize(termFD)
    termState, _ := terminal.MakeRaw(termFD)
    defer terminal.Restore(termFD, termState)
    err = session.RequestPty("xterm-256color", h, w, modes)
    if err != nil {
        log.Fatal(err)
    }
    session.Shell()
    session.Wait()
}2、命令方式
- 非交互命令(执行即返回结果) 
package main
import (
    "log"
    "os"
    "golang.org/x/crypto/ssh"
)
func main() {
    config := &ssh.ClientConfig{
        User: "demo",
        Auth: []ssh.AuthMethod{ssh.Password("123456")},
    }
    client, err := ssh.Dial("tcp", "127.0.0.1:19022", config)
    if err != nil {
        log.Fatal(err)
    }
    session, err := client.NewSession()
    if err != nil {
        log.Fatal(err)
    }
    defer session.Close()
    session.Stdout = os.Stdout
    session.Stderr = os.Stderr
    err = session.Run("hostname")
    if err != nil {
        log.Println(err)
    }
}- 交互命令(需tty支持,交互式输入输出) 
package main
import (
    "log"
    "os"
    "golang.org/x/crypto/ssh"
    "golang.org/x/crypto/ssh/terminal"
)
func main() {
    config := &ssh.ClientConfig{
        User: "demo",
        Auth: []ssh.AuthMethod{ssh.Password("123456")},
    }
    client, err := ssh.Dial("tcp", "127.0.0.1:19022", config)
    if err != nil {
        log.Fatal(err)
    }
    session, err := client.NewSession()
    if err != nil {
        log.Fatal(err)
    }
    defer session.Close()
    session.Stdout = os.Stdout
    session.Stderr = os.Stderr
    session.Stdin = os.Stdin
    modes := ssh.TerminalModes{
        ssh.ECHO:          1,
        ssh.TTY_OP_ISPEED: 14400,
        ssh.TTY_OP_OSPEED: 14400,
    }
    termFD := int(os.Stdin.Fd())
    w, h, _ := terminal.GetSize(termFD)
    termState, _ := terminal.MakeRaw(termFD)
    defer terminal.Restore(termFD, termState)
    err = session.RequestPty("xterm", h, w, modes)
    if err != nil {
        log.Fatal(err)
    }
    err = session.Run("hostname")
    if err != nil {
        log.Fatal(err)
    }
}3、sshAgent机制
sshAgent 严格来说是一个客户端认证机制,客户端通过和 SSH_AUTH_SOCK 这个环境变量指向的 Unix Domain Socket(本质上是 SSHD 建立的)通信,以获取其存储的私钥(实际也是保存在内存中,用户不可见),以此作为 ssh 的客户端验证方式:

package main
import (
    "log"
    "os"
    "golang.org/x/crypto/ssh"
    "golang.org/x/crypto/ssh/agent"
    "net"
)
func main() {
    sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
    signers := ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers)
    if err != nil {
        log.Fatal(err)
    }
    config := &ssh.ClientConfig{
        User: "demo",
        Auth: []ssh.AuthMethod{signers},
        HostKeyCallback: ssh.InsecureIgnoreHostKey(),
    }
    client, err := ssh.Dial("tcp", "127.0.0.1:19022", config)
    if err != nil {
        log.Fatal(err)
    }
    session, err := client.NewSession()
    if err != nil {
        log.Fatal(err)
    }
    defer session.Close()
    combo, err := session.CombinedOutput("who;hostname")
    if err != nil {
        log.Fatal(err)
    }
    log.Println("output: ", string(combo))
}在此之前,需要在机器上开启 ssh-agent 功能及通过 ssh-add 向其中添加私钥:
eval `ssh-agent -s` ssh-add ~/.ssh/id_rsa env|grep SSH_AUTH_SOCK
4、hostkey认证
HostKeyCallback 为客户端验证 hostkey 的回调接口,通常我们都设置为 ssh.InsecureIgnoreHostKey(),但是这样是不安全的(SSH中间人攻击, 可参考https://www.ssh.com/attack/man-in-the-middle)
更安全的做法,是在这里检查上一次登录中保存的机器指纹数据(或者系统采集的机器指纹数据)与登录时的指纹进行比对,相同才放行,HostKeyCallback 的原型如下:
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
    // 实现机器指纹验证逻辑
    return nil
},package main
import (
    kh "golang.org/x/crypto/ssh/knownhosts"
    "golang.org/x/crypto/ssh"
    "log"
    "os"
    "fmt"
)
func main() {
    hostKeyCallback, err := kh.New("/root/.ssh/known_hosts")
    if err != nil {
        log.Fatal("could not create hostkeycallback function:", err)
    }
    config := &ssh.ClientConfig{
        User: "demo",
        Auth: []ssh.AuthMethod{
            ssh.Password("123123"),
        },
        HostKeyCallback: hostKeyCallback,
    }
    client, err := ssh.Dial("tcp", "127.0.0.1:19022", config)
    if err != nil {
        log.Fatalf("unable to connect: %v", err)
    }
    defer client.Close()
    session, err := client.NewSession()
    if err != nil {
        log.Fatal("unable to create SSH session:", err)
    }
    defer session.Close()
    session.Stdout = os.Stdout
    session.Run(command)
    fmt.Println("done")
}5、keepalive机制
如何实现 SSH 客户端的 keepalive 机制?答案是使用 SendRequest 定时向服务端发送心跳包,Stack Overflow 上给出了解决方案:
func SSHDialTimeout(network, addr string, config *ssh.ClientConfig, timeout time.Duration) (*ssh.Client, error) {
    conn, err := net.DialTimeout(network, addr, timeout)
    if err != nil {
        return nil, err
    }
    timeoutConn := &Conn{conn, timeout, timeout}
    c, chans, reqs, err := ssh.NewClientConn(timeoutConn, addr, config)
    if err != nil {
        return nil, err
    }
    client := ssh.NewClient(c, chans, reqs)
    // this sends keepalive packets every 2 seconds
    // there's no useful response from these, so we can just abort if there's an error
    go func() {
        t := time.NewTicker(2 * time.Second)
        defer t.Stop()
        for range t.C {
            _, _, err := client.Conn.SendRequest("keepalive@golang.org", true, nil)
            if err != nil {
                return
            }
        }
    }()
    return client, nil}不过,在现网中,我们是通过在交互式的 session 发送 SendRequest 来实现 keepalive,如下面的客户端代码:
func Client() {
        //....
        session, _ := client.NewSession()
        defer session.Close()
        session.Stdout = os.Stdout
        session.Stderr = os.Stderr
        session.Stdin = os.Stdin
        modes := ssh.TerminalModes{
                ssh.ECHO:          1,
                ssh.ECHOCTL:       0,
                ssh.TTY_OP_ISPEED: 14400,
                ssh.TTY_OP_OSPEED: 14400,
        }
        termFD := int(os.Stdin.Fd())
        w, h, _ := terminal.GetSize(termFD)
        termState, _ := terminal.MakeRaw(termFD)
        defer terminal.Restore(termFD, termState)
        err = session.RequestPty("xterm-256color", h, w, modes)
        go func() {
                for {
                        time.Sleep(time.Second * time.Duration(30))
                        // 在当前的 session 中发送 keepalive
                        session.SendRequest("keepalive@openssh.com", true, nil)
                }
        }()
        session.Shell()
        session.Wait()
        //...}有个细节是,SSHD 服务端必须对 keepalive@openssh.com 这个 Request 有 reply 行为,不然的话,客户端会阻塞在 session.SendRequest 方法上;SSHD 服务端需要添加如下代码:
go func() {
    for req := range requests {
        switch req.Type {
        //......
        case "keepalive@openssh.com":
            req.Reply(true, nil)
        }
}}()6、SetEnv问题
SSH 客户端可以使用 Setenv 方法在 ssh 会话中设置环境变量,其原理也是通过 ssh.SendRequest 方法,但是这里需要注意的是:
在 SSH 服务端的配置 /etc/ssh/sshd_config 中需要加上这样的配置,如下:
AcceptEnv ENV_NAME
这样,客户端就可以设置名字为 ENV_NAME 的环境变量了。
func (s *Session) Setenv(name, value string) error {
	msg := setenvRequest{
		Name:  name,
		Value: value,
	}
	ok, err := s.ch.SendRequest("env", true, Marshal(&msg))
	if err == nil && !ok {
		err = errors.New("ssh: setenv failed")
	}
	return err}参考
https://smallstep.com/blog/ssh-agent-explained/
https://blog.csdn.net/sdcxyz/article/details/41487897
https://github.com/parsiya/SSH-Scanner/blob/master/SSHHarvesterv1.go?ts=4
https://github.com/pandaychen/golang_in_action/blob/master/sshserver/sshserver.go#L167
