1. SSH客户端使用
在运维开发中,有时会涉及到在目标机器上执行shell命令,或者需要通过ssh通道传输文件,此时ssh客户端的使用就必不可少。如果涉及频繁连接SSH客户端,建议使用连接池进行管理,降低SSH开销,同时避免过多的ssh连接导致被跳板机拒绝。使用golang实现ssh客户端场景较多,实现ssh服务端场景较少,此处仅实现ssh客户端作为示例。涉及到的包有:
golang.org/x/crypto/sshgithub.com/bramvdbogaerde/go-scp1.1. 使用ssh client
在实际操作中,分两种情况,一种是直接操作目标主机,一种是通过跳板机操作目标机器,下面代码对两种情况进行了ssh.Client实现! ```go // 目标主机信息 type Host struct { IP string SSHPort int Username string Password string SSHKey string JumpServer *JumpServer }
// 跳板机 type JumpServer struct { IP string SSHPort int Username string Password string SSHKey string }
// 生成密钥信息, 分为两种:password 和 ssh key func sshClientConfig(passwd, key string) (auth []ssh.AuthMethod, err error) { if passwd != “” { auth = append(auth, ssh.Password(passwd)) } if key != “” { privateKey, err := ssh.ParsePrivateKey([]byte(key)) if err != nil { return nil, err } auth = append(auth, ssh.PublicKeys(privateKey)) } return }
// 生成ssh 客户端信息 func opensshClient(host Host) (client ssh.Client, err error) { if host.JumpServer == nil { return clientWithoutJumpServer(host) } return clientWithJumpServer(host) }
// 如果没有跳板机,则直接连接目标机器 func clientWithoutJumpServer(host Host) (client ssh.Client, err error) { auth, err := sshClientConfig(host.Password, host.SSHKey) // 生成密钥 if err != nil { return nil, err } // 生成ssh client的配置 config := &ssh.ClientConfig{ User: host.Username, Auth: auth, HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { return nil }, Timeout: time.Second * 10, } // 拨号 client, err = ssh.Dial(“tcp”, fmt.Sprintf(“%s:%d”, host.IP, host.SSHPort), config) if err != nil { return nil, err } return }
// 如果存在跳板机,则进行中转 func clientWithJumpServer(host Host) (client ssh.Client, err error) { // 生成跳板机密钥 jAuth, err := sshClientConfig(host.JumpServer.Password, host.JumpServer.SSHKey) if err != nil { return nil, err } // 生成跳板机的ssh client配置 jConfig := &ssh.ClientConfig{ User: host.JumpServer.Username, Auth: jAuth, HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { return nil }, Timeout: time.Second 10, } // 对跳板机进行拨号 jClient, err := ssh.Dial(“tcp”, fmt.Sprintf(“%s:%d”, host.JumpServer.IP, host.JumpServer.SSHPort), jConfig) if err != nil { return nil, err } // 生成目标机器的密钥 auth, err := sshClientConfig(host.Password, host.SSHKey) if err != nil { return nil, err } // 生成目标机器的ssh client配置 config := &ssh.ClientConfig{ User: host.Username, Auth: auth, HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { return nil }, Timeout: time.Second 10, } // 使用跳板机对目标机器进行拨号 conn, err := jClient.Dial(“tcp”, fmt.Sprintf(“%s:%d”, host.IP, host.SSHPort)) if err != nil { return nil, err } // 生成目标机器的 ssh client clientConn, channels, requests, err := ssh.NewClientConn(conn, fmt.Sprintf(“%s:%d”, host.IP, host.SSHPort), config) if err != nil { return nil, err } return ssh.NewClient(clientConn, channels, requests), nil }
<a name="wQpml"></a>### 1.2. 执行命令```gofunc execCommand(client *ssh.Client, cmd ...string) (stdout, stderr string, err error) {session, err := client.NewSession() // 开启新的ssh会话if err != nil {return "", "", err}defer func() { _ = session.Close() }()// 指定标准输出和标准错误var stdOut bytes.Buffervar stdErr bytes.Buffersession.Stderr = &stdErrsession.Stdout = &stdOutif err := session.Run(strings.Join(cmd, " && ")); err != nil {return stdOut.String(), stdErr.String(), err}return stdOut.String(), stdErr.String(), nil}
// 之间连接对于主机,并执行命令func main() {node100 := &Host{IP: "10.4.7.100",SSHPort: 22,Username: "root",SSHKey: func() string {content, _ := ioutil.ReadFile("/root/.ssh/id_rsa")return string(content)}(),}client, err := opensshClient(node100)if err != nil {logger.Errorf("open ssh connect to %s failed, error:%s", node100.IP, err.Error())return}defer func() { _ = client.Close() }()stdout, stderr, err := execCommand(client, "echo $HOSTNAME", "df -h")if err != nil {logger.Errorf("%s run command echo $HOSTNAME failed, stdout:%s, stderr:%s, err:%s", node100.IP, stdout, stderr, err.Error())return}fmt.Print(stdout)fmt.Print(stderr)}
[root@duduniao ssh]# go run command.gojumpserver-100Filesystem Size Used Avail Use% Mounted onudev 1.9G 0 1.9G 0% /devtmpfs 393M 1.1M 392M 1% /run/dev/sda2 20G 7.4G 12G 40% /tmpfs 2.0G 0 2.0G 0% /dev/shmtmpfs 5.0M 0 5.0M 0% /run/locktmpfs 2.0G 0 2.0G 0% /sys/fs/cgrouptmpfs 393M 0 393M 0% /run/user/0
// 存在跳板机的情况func main() {node101 := &Host{IP: "10.4.7.101",SSHPort: 22,Username: "root",SSHKey: func() string {content, _ := ioutil.ReadFile("/root/.ssh/id_rsa")return string(content)}(),JumpServer: &JumpServer{IP: "10.4.7.100",SSHPort: 22,Username: "root",SSHKey: func() string {content, _ := ioutil.ReadFile("/root/.ssh/id_rsa")return string(content)}(),},}client, err := opensshClient(node101)if err != nil {logger.Errorf("open ssh connect to %s failed, error:%s", node101.IP, err.Error())return}defer func() { _ = client.Close() }()stdout, stderr, err := execCommand(client, "echo $HOSTNAME", "df -h")if err != nil {logger.Errorf("%s run command echo $HOSTNAME failed, stdout:%s, stderr:%s, err:%s", node101.IP, stdout, stderr, err.Error())return}fmt.Print(stdout)fmt.Print(stderr)}
[root@duduniao ssh]# go run command.goworker-101Filesystem Size Used Avail Use% Mounted onudev 1.9G 0 1.9G 0% /devtmpfs 393M 1.2M 392M 1% /run/dev/sda2 20G 7.4G 12G 40% /tmpfs 2.0G 0 2.0G 0% /dev/shmtmpfs 5.0M 0 5.0M 0% /run/locktmpfs 2.0G 0 2.0G 0% /sys/fs/cgroup10.4.7.100:/data/nfs 20G 7.4G 12G 40% /data/nfstmpfs 393M 0 393M 0% /run/user/0
1.3. 转发文件
// 发送文件func sendFiles(client *ssh.Client, remoteDir string, localFile string) error {// github.com/bramvdbogaerde/go-scp// 调用scp模块,创建scp的clientscpClient, err := scp.NewClientBySSH(client)if err != nil {return err}defer func() { _ = scpClient.Close }()if err := scpClient.Connect(); err != nil {return err}file, err := os.Open(localFile)if err != nil {return err}// 这里存在几个问题:// 1. 文件权限必须要是字符串的数字格式。无法使用 file.stat 中的mode// 2. 同一个 scpClient 只能发送一次文件,发多个文件会出现: ssh: StdinPipe after process started// 3. 应该有其它的模块能改进避免上述的俩个文件if err := scpClient.CopyFile(file, path.Join(remoteDir, path.Base(localFile)), "0755"); err != nil {return err}_ = file.Close()return nil}
func main() {node101 := &Host{IP: "10.4.7.101",SSHPort: 22,Username: "root",SSHKey: func() string {content, _ := ioutil.ReadFile("/root/.ssh/id_rsa")return string(content)}(),JumpServer: &JumpServer{IP: "10.4.7.100",SSHPort: 22,Username: "root",SSHKey: func() string {content, _ := ioutil.ReadFile("/root/.ssh/id_rsa")return string(content)}(),},}client, err := opensshClient(node101)if err != nil {logger.Errorf("open ssh connect to %s failed, error:%s", node101.IP, err.Error())return}defer func() { _ = client.Close() }()err = sendFiles(client, "/tmp", "/root/bin/scan_host.sh")if err != nil {logger.Errorf("%s send file failed ,err:%s", node101.IP, err.Error())}}
2. SSH作为代理转发
ssh 服务可以作为隧道进行请求的转发,比如目标机器上存在一个nginx服务器,对外暴露80端口,但是当前服务器与目标机器网络不通,此时可以通过中间的跳板机上ssh通道进行HTTP的请求转发!
// 这里面涉及的channel让gc进行回收,手动关闭容易出现panic// 端口转发func forward(client *ssh.Client, protocol, localAddr, remoteAddr string, stop chan bool, errMsg chan error) {// 打开本地端口listener, err := net.Listen(protocol, localAddr)if err != nil {errMsg <- errreturn}defer func() { _ = listener.Close() }()// 定义异常退出机制,因为设置了 stop chan,为了避免在stop chan阻塞,引入err chan,两者满足其一就能退出var errChan = make(chan error)// 循环接收本地端口的请求go func() {for {localConn, err := listener.Accept()if localConn == nil {errMsg <- errreturn}if err != nil {_ = localConn.Close()errMsg <- errreturn}go establishLocal(client, protocol, remoteAddr, localConn, errChan)}}()select {case <-stop:errMsg <- nilcase err := <-errChan:errMsg <- err}}// 处理本地端口的请求func establishLocal(client *ssh.Client, protocol, remoteAddr string, local net.Conn, errChan chan error) {// 打开远程的端口, 每次接收一个新的TCP连接,都得开一次远程转发remote, err := client.Dial(protocol, remoteAddr)if err != nil {errChan <- errreturn}defer func() { _ = remote.Close() }()errCh := make(chan error, 1)go exchangeData(local, remote, errCh)go exchangeData(remote, local, errCh)<-errCh<-errCh}type closeWriter interface {CloseWrite() error}// 数据交换func exchangeData(r io.Reader, w io.Writer, errCh chan error) {_, err := io.Copy(w, r)if tcpConn, ok := w.(closeWriter); ok {_ = tcpConn.CloseWrite() // 必须要关闭,否则内存泄露}errCh <- err}
func main() {node101 := &Host{IP: "10.4.7.101",SSHPort: 22,Username: "root",SSHKey: func() string {content, _ := ioutil.ReadFile("/root/.ssh/id_rsa")return string(content)}(),JumpServer: &JumpServer{IP: "10.4.7.100",SSHPort: 22,Username: "root",SSHKey: func() string {content, _ := ioutil.ReadFile("/root/.ssh/id_rsa")return string(content)}(),},}client, err := opensshClient(node101)if err != nil {logger.Errorf("open ssh connect to %s failed, error:%s", node101.IP, err.Error())return}defer func() { _ = client.Close() }()stop := make(chan bool, 1)errMsg := make(chan error, 1)go forward(client, "tcp", "127.0.0.1:10080", "172.17.0.2:80", stop, errMsg)// 测试网络隧道是否就绪, 因为使用goroutine打开隧道,本地通道可能还能没有就绪for i := 0; i < 10; i++ {_, err = net.DialTimeout("tcp", "127.0.0.1:10080", time.Millisecond*100)if err == nil {break}}if err != nil {stop <- truereturn}// 测试for i := 0; i < 100000; i++ {httpClient := http.Client{Timeout: time.Second}resp, err := httpClient.Get("http://127.0.0.1:10080/info")if err != nil {logger.Errorf("send request failed, err:%s", err.Error())break}content, _ := ioutil.ReadAll(resp.Body)_ = resp.Body.Close()fmt.Print(string(content))time.Sleep(time.Millisecond)}stop <- true}
