使用go的ssh包快速打造一个本地命令行ssh客户端

热身运动🏂

在开始之前,先来个热身运动。虽然标题党写着快速打造一个ssh客户端,但是和跑步一样,在运动前还是需要先热身一下,不然到时候身体(大脑)会吃不消。所以,在开始前,我们先来科普一下ssh的一些东西。

先来说说ssh,这里的ssh是指由IETF的网络小组(Network Working Group)所制定的为建立在应用层和传输层基础上的安全协议。(对于了解这个协议的请忽略本段文字😱)点这里了解更多ssh介绍

写过java web应用的同学应该还知道另一个ssh(struts+spring+hibernate),当然今天的主角并不是它。😅

其实接触过后端开发的同学对于ssh应该都不陌生,可能每天你都在使用它,没错,当你要远程登录服务器的时候,大多数情况下都离不开它,俨然已经成为Linux系统的标准配置。所以,如果你使用的是Linux操作系统,那么默认情况下就已经自带ssh的客户端了,于是乎你直接可以在Linux的shell中执行: ssh user@host 就可以安全的登录到了远程主机host。对于ssh的更多命令或者玩法今天就不多介绍了,因为这不是今天的主要目标,今天的主要任务是实现一个和Linux操作系统中默认自带的ssh命令行客户端一样的使用go语言开发的ssh命令行客户端,当然由于时间篇幅有限,这次并不会实现原生ssh命令行客户端的全部功能,主要是能够实现远程登录到远程host,并能进行命令行操作。对于其他高级命令,如端口转发等将在后续完成。

工欲善其事必先利其器🔪

既然说了要快速打造,那么必然需要借助一些现有的工具包了,这边为了完成这个客户端,笔者对原生的go语言的ssh包进行了一下封装做了一个小工具包gosshtool,可以从github找到。有了它,再来做ssh的客户端就轻松多了。

 

开始设计💻

首先,要完成一个命令行的ssh客户端,我们先来看下Linux下自带的ssh客户端是怎么工作的。这里所说的怎么工作,会站在比较高层的角度,因为ssh的整个通讯协议比较复杂,这里不过多介绍,原因是go提供的ssh包已经把底层的一些协议实现了,这里没必要自己再写一套实现出来,如果你确实对底层协议有兴趣,可以自己去网上查阅文档。那么站在比较高层角度来看,是如何的呢? >我们还原一个最常见的场景:某一天,你想登录远程主机,于是你打开了Linux的shell, 输入

  1. ssh user@host

然后输入密码后顺利的登录了host这个主机,接着你在shell输入一些命令,比如

  1. ls

查看远程主机当前目录下所有文件。

上述场景的过程,我们可以简单画一个图,来看看你这些操作是怎么与远程主机通讯的,如下图: 

 

根据上图,我们开始设计,首先要想办法读取用户的键盘输入,如:输入pwd 在go语言中,我们可以使用os和bufio两个包,关键代码如下:

  1. inputReader := bufio.NewReader(os.Stdin)
  2. input, err := inputReader.ReadString('\n')

如上代码,我们就可以读取以换行结束的字符串。

这样完成了图中的第一步,第二步,我们将要建立与远程主机的ssh连接,这时候可以用到前面介绍的工具gosshtool了,有了它完成这一步变得轻松许多。在介绍这一步之前,我们先来对这个将要实现的客户端再多啰嗦几句,为了使我们的客户端看起来更像Linux自带的ssh客户端,我们假设将要做的这个客户端名字叫sshcmd,我们将要完成的任务是到时候生成一个叫sshcmd的可执行文件,然后执行

  1. ./sshcmd user@host

就建立了远程ssh连接,并返回远程主机登录信息,接着你可以继续在控制台输入后续命令,这些命令实际上是在远程主机执行的,就像Linux自带的ssh客户端一样。所以,我们还要用到go的一个叫做flag的包,这个包在写命令行程序的时候非常有用,它可以方便的对命令参数进行解析。所以我们会写到如下关键代码:

  1. func main() {
  2. flag.StringVar(&host, "h", "", "host")
  3. flag.StringVar(&passwd, "p", "", "password")
  4. flag.Parse()
  5. hostsp := strings.Split(host, "@")
  6. user = hostsp[0]
  7. host = hostsp[1]
  8. }

我们从命令行读取了user,host,password三个重要参数。有了它们,可以就可以建立ssh连接了关键代码如下:

  1. config := &gosshtool.SSHClientConfig{
  2. User: user,
  3. Password: passwd,
  4. Host: host,
  5. }
  6. sshclient := gosshtool.NewSSHClient(config)
  7. _, err := sshclient.Connect()
  8. if err == nil {
  9. fmt.Println("ssh connect success")
  10. } else {
  11. fmt.Println("ssh connect failed")
  12. }
  13. modes := ssh.TerminalModes{
  14. ssh.ECHO: 0,
  15. ssh.TTY_OP_ISPEED: 14400,
  16. ssh.TTY_OP_OSPEED: 14400,
  17. }
  18. pty := &gosshtool.PtyInfo{
  19. Term: "xterm-256color",
  20. H: 80,
  21. W: 40,
  22. Modes: modes,
  23. }
  24. session, err := sshclient.Pipe(conn, pty, nil, 30)
  25. if err != nil {
  26. fmt.Println(err)
  27. }
  28. defer session.Close()

 

我们使用了gosshtool的NewSSHClient方法创建了一个客户端,并调用Connect()建立了连接,最后使用了Pipe(conn, pty, nil, 30)方法创建了一个保持会话,这样就号好了。这一切看起来如此简单,都要归功于Pipe这个方法,它的第一个参数是一个ReadWriteCloser接口类型,只要实现了该接口的结构都可以传入,这里我们会使用TCPConn这个结构,该结构实现了net.Conn接口,而net.Conn接口也是实现了ReadWriteCloser接口的。这个参数非常重要,我们建立了连接后,后续的通信全靠它了。你如果熟悉ReadWriteCloser接口,其实你就知道这个接口又组合了三个接口:

  1. type ReadWriteCloser interface {
  2. Reader
  3. Writer
  4. Closer
  5. }
  6.  
  7. type Writer interface {
  8. Write(p []byte) (n int, err error)
  9. }
  10.  
  11. type Reader interface {
  12. Read(p []byte) (n int, err error)
  13. }
  14.  
  15. type Closer interface {
  16. Close() error
  17. }

再看net.Conn接口:

我们使用了gosshtool的NewSSHClient方法创建了一个客户端,并调用Connect()建立了连接,最后使用了Pipe(conn, pty, nil, 30)方法创建了一个保持会话,这样就号好了。这一切看起来如此简单,都要归功于Pipe这个方法,它的第一个参数是一个ReadWriteCloser接口类型,只要实现了该接口的结构都可以传入,这里我们会使用TCPConn这个结构,该结构实现了net.Conn接口,而net.Conn接口也是实现了ReadWriteCloser接口的。这个参数非常重要,我们建立了连接后,后续的通信全靠它了。你如果熟悉ReadWriteCloser接口,其实你就知道这个接口又组合了三个接口:

  1. type ReadWriteCloser interface {
  2. Reader
  3. Writer
  4. Closer
  5. }
  6.  
  7. type Writer interface {
  8. Write(p []byte) (n int, err error)
  9. }
  10.  
  11. type Reader interface {
  12. Read(p []byte) (n int, err error)
  13. }
  14.  
  15. type Closer interface {
  16. Close() error
  17. }

再看net.Conn接口:

 

  1. type Conn interface {
  2. // Read从连接中读取数据
  3. // Read方法可能会在超过某个固定时间限制后超时返回错误,该错误的Timeout()方法返回真
  4. Read(b []byte) (n int, err error)
  5. // Write从连接中写入数据
  6. // Write方法可能会在超过某个固定时间限制后超时返回错误,该错误的Timeout()方法返回真
  7. Write(b []byte) (n int, err error)
  8. // Close方法关闭该连接
  9. // 并会导致任何阻塞中的Read或Write方法不再阻塞并返回错误
  10. Close() error
  11. // 返回本地网络地址
  12. LocalAddr() Addr
  13. // 返回远端网络地址
  14. RemoteAddr() Addr
  15. // 设定该连接的读写deadline,等价于同时调用SetReadDeadline和SetWriteDeadline
  16. // deadline是一个绝对时间,超过该时间后I/O操作就会直接因超时失败返回而不会阻塞
  17. // deadline对之后的所有I/O操作都起效,而不仅仅是下一次的读或写操作
  18. // 参数t为零值表示不设置期限
  19. SetDeadline(t time.Time) error
  20. // 设定该连接的读操作deadline,参数t为零值表示不设置期限
  21. SetReadDeadline(t time.Time) error
  22. // 设定该连接的写操作deadline,参数t为零值表示不设置期限
  23. // 即使写入超时,返回值n也可能>0,说明成功写入了部分数据
  24. SetWriteDeadline(t time.Time) error
  25. }

对比下会发现Conn接口也实现了

  • Read(b []byte) (n int, err error)
  • Write(b []byte) (n int, err error)
  • Close() error

这也说明了,确实我们可以将net.Conn的参数传入。通过接口方法,其实也可以看出这些接口都有一个共同作用,可以对字节进行读写操作。而我们要与远程主机网络通信,当然少不了这些。因此,所有实现以上三个方法的结构都是可以传入并于远程主机建立的ssh连接通信的。这里,我们的想法是:在本地起一个socket服务,并接受标准输入,最终将标准输入的数据通过Pipe转发给远程主机,实现本地终端输入命令通过ssh协议远程执行如下图:

正如图中所示,实际上Pipe方法可以理解为将tcp连接转成了ssh连接并可以通过它传递数据。当然也可以将websocket的连接转成ssh连接,这样就可以实现基于web网页的ssh客户端了,也是非常简单的,这个后续介绍。介绍到这里,大部分关键的点都已经说完了,这里只是简单实现了一个最简单版本的ssh命令行客户端,当然通过gosshtool还可以做很多好玩的东西,比如部署工具,本地转发服务,命令行运维工具等。最后,最最关键的,放上本次实践的完整源码: sshcmd源码

总结

本文介绍了如何打造一个本地命令行ssh客户端,如果基于现成的工具包确实没多少工作量,而且大部分功能都实现比较粗糙,权当抛砖引玉。

文档信息

 

posted @ 2016-06-20 15:17 Sirk 阅读(...) 评论(...) 编辑 收藏