1
golang.org/x/crypto/ssh

发送指令执行 session.Run()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
"bytes"
"fmt"
"golang.org/x/crypto/ssh"
"log"
)

func main() {
// 建立SSH客户端连接
client, err := ssh.Dial("tcp", "127.0.0.1:2222", &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password("123456")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
// Timeout: time.Second, //ssh 连接timeout时间一秒钟, 如果ssh验证错误,会在一秒内返回
})
if err != nil {
log.Fatalf("SSH dial error: %s", err.Error())
}

// 建立新会话
// A Session only accepts one call to Run, Start, Shell, Output, or CombinedOutput.
// session只能执行一次Run, Start, Shell, Output, or CombinedOutput
session, err := client.NewSession()
if err != nil {
log.Fatalf("new session error: %s", err.Error())
}

defer session.Close()


var b bytes.Buffer
session.Stdout = &b
// Session.Run allows only one command per session
if err := session.Run("ls"); err != nil {
panic("Failed to run: " + err.Error())
}
fmt.Println(b.String())
}

发送指令执行 session.Output()

session.run(command) 是直接在 host 执行命令,不关心执行结果。session.Output 是将执行命令之后的 Stdout 返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"fmt"
"golang.org/x/crypto/ssh"
"log"
"os"
)

func test() {
// 建立SSH客户端连接
client, err := ssh.Dial("tcp", "127.0.0.1:2222", &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password("123456")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
log.Fatalf("SSH dial error: %s", err.Error())
}

// 建立新会话
session, err := client.NewSession()
defer session.Close()
if err != nil {
log.Fatalf("new session error: %s", err.Error())
}

result, err := session.Output("ls -al")
if err != nil {
fmt.Fprintf(os.Stdout, "Failed to run command, Err:%s", err.Error())
os.Exit(0)
}
fmt.Println(string(result))

//执行远程命令
combo,err := session.CombinedOutput("whoami; cd /; ls -al;echo https://github.com/dejavuzhou/felix")
if err != nil {
log.Fatal("远程执行cmd 失败",err)
}
log.Println("命令输出:",string(combo))
}

模拟交互 terminal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
"golang.org/x/crypto/ssh"
"log"
"os"
)

func main() {
// 建立SSH客户端连接
client, err := ssh.Dial("tcp", "127.0.0.1:2222", &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password("123456")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
log.Fatalf("SSH dial error: %s", err.Error())
}

// 建立新会话
session, err := client.NewSession()
defer session.Close()
if err != nil {
log.Fatalf("new session error: %s", err.Error())
}

session.Stdout = os.Stdout // 会话输出关联到系统标准输出设备
session.Stderr = os.Stderr // 会话错误输出关联到系统标准错误输出设备
session.Stdin = os.Stdin // 会话输入关联到系统标准输入设备
modes := ssh.TerminalModes{
ssh.ECHO: 0, // 禁用回显(0禁用,1启动)
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
}
if err = session.RequestPty("linux", 32, 160, modes); err != nil {
log.Fatalf("request pty error: %s", err.Error())
}
if err = session.Shell(); err != nil {
log.Fatalf("start shell error: %s", err.Error())
}
if err = session.Wait(); err != nil {
log.Fatalf("return error: %s", err.Error())
}
}
1
2
3
4
5
6
7
8
9
//如果不禁用回显
[root@65a9c031a770 ~]# ls
ls
anaconda-ks.cfg


//禁用回显
[root@65a9c031a770 ~]# ls
anaconda-ks.cfg

注意:

这里的 ssh.InsecureIgnoreHostKey 是不检查 host key,需要检查的话得参考 client 源码重写函数

ssh 公钥登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func SSHConnect(user, host string, port int) (*ssh.Client, error) {
var (
addr string
clientConfig *ssh.ClientConfig
client *ssh.Client
err error
)

homePath, err := os.UserHomeDir()
if err != nil {
return nil, err
}
key, err := ioutil.ReadFile(path.Join(homePath, ".ssh", "id_rsa"))
if err != nil {
return nil, err
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, err
}

clientConfig = &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
Timeout: 30 * time.Second,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}

// connet to ssh
addr = fmt.Sprintf("%s:%d", host, port)

if client, err = ssh.Dial("tcp", addr, clientConfig); err != nil {
err = errors.Wrapf(err, "")
return nil, err
}

return client, nil
}

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"fmt"
"golang.org/x/crypto/ssh"
)

const privateKey = `content of id_rsa`

func main() {
signer, _ := ssh.ParsePrivateKey([]byte(privateKey))
clientConfig := &ssh.ClientConfig{
User: "jedy",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
}
client, err := ssh.Dial("tcp", "127.0.0.1:22", clientConfig)
if err != nil {
panic("Failed to dial: " + err.Error())
}
session, err := client.NewSession()
if err != nil {
panic("Failed to create session: " + err.Error())
}
defer session.Close()

go func() {
w, _ := session.StdinPipe()
defer w.Close()
content := "123456789\n"
fmt.Fprintln(w, "D0755", 0, "testdir") // mkdir
fmt.Fprintln(w, "C0644", len(content), "testfile1")
fmt.Fprint(w, content)
fmt.Fprint(w, "\x00") // transfer end with \x00
fmt.Fprintln(w, "C0644", len(content), "testfile2")
fmt.Fprint(w, content)
fmt.Fprint(w, "\x00")
}()
if err := session.Run("/usr/bin/scp -tr ./"); err != nil {
panic("Failed to run: " + err.Error())
}
}

解决 SSH 远程执行命令找不到环境变量的问题

  • 通过 SSH 执行远程主机的命令或脚本时,经常会出现找不到自定义环境变量的问题。但是,如果通过 SSH 登录远程主机,然后再执行相同的命令或脚本,那么此时执行又是成功的。
  • 两种相似的方法,得到的结果却截然不同,看起来很诡异的现象,根本原因在于这两种方式使用的 bash 模式不同!

1. 通过 SSH 登录后再执行命令和脚本

这种方式会使用 Bash 的 interactive + login shell 模式,这里面有两个概念需要解释:interactivelogin

  • login: 故名思义,即登陆。login shell 是指用户以非图形化界面或者以 ssh 登陆到机器上时获得的第一个 shell,简单些说就是需要输入用户名和密码的 shell。因此通常不管以何种方式登陆机器后用户获得的第一个 shell 就是 login shell。
  • interactive: 意为交互式,这也很好理解,interactive shell 会有一个输入提示符,并且它的标准输入、输出和错误输出都会显示在控制台上。所以一般来说只要是需要用户交互的,即一个命令一个命令的输入的 shell 都是 interactive shell。而如果无需用户交互,它便是 non-interactive shell。通常来说如 bash script.sh 此类执行脚本的命令就会启动一个 non-interactive shell,它不需要与用户进行交互,执行完后它便会退出创建的 Shell。

在 interactive + login shell 模式中,Shell 首先会加载 /etc/profile 文件,然后再尝试依次去加载下列三个配置文件之一,一旦找到其中一个便不再接着寻找:

  • ~/.bash_profile
  • ~/.bash_login
  • ~/.profile

2. 通过 SSH 直接执行远程命令和脚本

这种方式会使用 Bash 的 non-interactive + non-login shell 模式,它会创建一个 shell,执行完脚本之后便退出,不再需要与用户交互。

no-login shell,顾名思义就是不是在登录 Linux 系统时启动的(比如你在命令行提示符上输入 bash 启动)。它不会去执行 /etc/profile 文件,而会去用户的 HOME 目录检查.bashrc 并加载。

系统执行 Shell 脚本的时候,就是属于这种 non-interactive shell。Bash 通过 BASH_ENV 环境变量来记录要加载的文件,默认情况下这个环境变量并没有设置。如果有指定文件,那么 Shell 会先去加载这个文件里面的内容,然后再开始执行 Shell 脚本。

3. 结论

由此可见,如果要解决 SSH 远程执行命令时找不到自定义环境变量的问题,那么可以在登录用户的 HOME 目录的.bashrc 中添加需要的环境变量。

4. 示例

当登录之后,直接在某台远程主机:10.0.63.9 上执行日期格式化的命令时,打印的是正确的,如下:

1
2
[root@dev-appserver2 ~]# buildTimeStamp=2017-09-27T16:58:47.291+08:00; buildTimeStampStr=$(date -d $buildTimeStamp "+%y%m%d%H%M%S"); echo $buildTimeStampStr
170927165847

当在另外一台主机(10.0.251.216)上远程执行 ssh 命令时,打印的结果不正确(差了八个时区),如下:

1
2
3
4
5
6
7
8
9
[root@host-10-0-251-216 ~]# cat sshtime
buildTimeStamp=2017-09-27T16:58:47.291+08:00; buildTimeStampStr=$(date -d $buildTimeStamp "+%y%m%d%H%M%S"); echo $buildTimeStampStr

[root@host-10-0-251-216 ~]# a=`cat sshtime `
[root@host-10-0-251-216 ~]# echo $a
buildTimeStamp=2017-09-27T16:58:47.291+08:00; buildTimeStampStr=$(date -d $buildTimeStamp "+%y%m%d%H%M%S"); echo $buildTimeStampStr
[root@host-10-0-251-216 ~]# ssh root@10.0.63.9 $a
root@10.0.63.9's password:
170927085847

此时修改 10.0.63.9 上,root 根目录下的.bashrc 文件,增加 TZ 的设置,再次执行 ssh 打印的结果是正确的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[root@dev-appserver2 ~]# cat .bashrc
# .bashrc

# User specific aliases and functions

alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'

export TZ="Asia/Shanghai"

# Source global definitions
if [ -f /etc/bashrc ]; then
. /etc/bashrc
fi
[root@dev-appserver2 ~]#
1
2
3
[root@host-10-0-251-216 ~]# ssh root@10.0.63.9 $a
root@10.0.63.9's password:
170927165847

5. 在 crypto/ssh 库中设置环境变量

简单来说就是,需要执行source /etc/profile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
"golang.org/x/crypto/ssh"
"log"
)

func main() {
// 建立SSH客户端连接
client, err := ssh.Dial("tcp", "127.0.0.1:22", &ssh.ClientConfig{
User: "heyingliang",
Auth: []ssh.AuthMethod{ssh.Password("liangbo4869")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
log.Fatalf("SSH dial error: %s", err.Error())
}

// 建立新会话
session, err := client.NewSession()
if err != nil {
log.Fatalf("new session error: %s", err.Error())
}
defer session.Close()

combo,err := session.CombinedOutput("source /etc/profile; docker ps")
// 如果不使用source /etc/profile,只能如下:
// combo,err := session.CombinedOutput("/usr/local/bin/docker ps")
if err != nil {
log.Fatal("远程执行cmd 失败",err)
}
log.Println("命令输出:",string(combo))
}

非交互模式是不能从 shell 进入到另一个 shell 的

所以,执行docker exec -i redis bash -c "ls;pwd"。注意 exec 的参数是-i,而不是-it

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"golang.org/x/crypto/ssh"
"log"
)

func main() {
client, err := ssh.Dial("tcp", "127.0.0.1:22", &ssh.ClientConfig{
User: "heyingliang",
Auth: []ssh.AuthMethod{ssh.Password("liangbo4869")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {
log.Fatalf("SSH dial error: %s", err.Error())
}

session, err := client.NewSession()
if err != nil {
log.Fatalf("new session error: %s", err.Error())
}
defer session.Close()

combo,err := session.CombinedOutput(`source /etc/profile; docker exec -i redis bash -c "ls;pwd"`)
if err != nil {
log.Fatal("远程执行cmd 失败",err)
}
log.Println("命令输出:",string(combo))
}

Reference

使用 GO 语言灵活批量 ssh 登录服务器执行操作: https://www.cnblogs.com/findumars/p/5930584.html

github 一个非常好的 web ssh 项目: https://github.com/libragen/felix