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