上一篇我们实现了一个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)
}
}
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
https://github.com/rinetd/ssh/blob/master/auth.go