热身运动
在开始之前,先来个热身运动。虽然标题党写着快速打造一个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, 输入
- ssh user@host
然后输入密码后顺利的登录了host这个主机,接着你在shell输入一些命令,比如
- ls
查看远程主机当前目录下所有文件。
上述场景的过程,我们可以简单画一个图,来看看你这些操作是怎么与远程主机通讯的,如下图:
根据上图,我们开始设计,首先要想办法读取用户的键盘输入,如:输入pwd
在go语言中,我们可以使用os和bufio两个包,关键代码如下:
- inputReader := bufio.NewReader(os.Stdin)
- input, err := inputReader.ReadString('\n')
如上代码,我们就可以读取以换行结束的字符串。
这样完成了图中的第一步,第二步,我们将要建立与远程主机的ssh连接,这时候可以用到前面介绍的工具gosshtool了,有了它完成这一步变得轻松许多。在介绍这一步之前,我们先来对这个将要实现的客户端再多啰嗦几句,为了使我们的客户端看起来更像Linux自带的ssh客户端,我们假设将要做的这个客户端名字叫sshcmd,我们将要完成的任务是到时候生成一个叫sshcmd的可执行文件,然后执行
- ./sshcmd user@host
就建立了远程ssh连接,并返回远程主机登录信息,接着你可以继续在控制台输入后续命令,这些命令实际上是在远程主机执行的,就像Linux自带的ssh客户端一样。所以,我们还要用到go的一个叫做flag的包,这个包在写命令行程序的时候非常有用,它可以方便的对命令参数进行解析。所以我们会写到如下关键代码:
- func main() {
- flag.StringVar(&host, "h", "", "host")
- flag.StringVar(&passwd, "p", "", "password")
- flag.Parse()
- hostsp := strings.Split(host, "@")
- user = hostsp[0]
- host = hostsp[1]
- }
我们从命令行读取了user,host,password三个重要参数。有了它们,可以就可以建立ssh连接了关键代码如下:
- config := &gosshtool.SSHClientConfig{
- User: user,
- Password: passwd,
- Host: host,
- }
- sshclient := gosshtool.NewSSHClient(config)
- _, err := sshclient.Connect()
- if err == nil {
- fmt.Println("ssh connect success")
- } else {
- fmt.Println("ssh connect failed")
- }
- modes := ssh.TerminalModes{
- ssh.ECHO: 0,
- ssh.TTY_OP_ISPEED: 14400,
- ssh.TTY_OP_OSPEED: 14400,
- }
- pty := &gosshtool.PtyInfo{
- Term: "xterm-256color",
- H: 80,
- W: 40,
- Modes: modes,
- }
- session, err := sshclient.Pipe(conn, pty, nil, 30)
- if err != nil {
- fmt.Println(err)
- }
- defer session.Close()
我们使用了gosshtool的NewSSHClient方法创建了一个客户端,并调用Connect()建立了连接,最后使用了Pipe(conn, pty, nil, 30)方法创建了一个保持会话,这样就号好了。这一切看起来如此简单,都要归功于Pipe这个方法,它的第一个参数是一个ReadWriteCloser接口类型,只要实现了该接口的结构都可以传入,这里我们会使用TCPConn这个结构,该结构实现了net.Conn接口,而net.Conn接口也是实现了ReadWriteCloser接口的。这个参数非常重要,我们建立了连接后,后续的通信全靠它了。你如果熟悉ReadWriteCloser接口,其实你就知道这个接口又组合了三个接口:
- type ReadWriteCloser interface {
- Reader
- Writer
- Closer
- }
-
- type Writer interface {
- Write(p []byte) (n int, err error)
- }
-
- type Reader interface {
- Read(p []byte) (n int, err error)
- }
-
- type Closer interface {
- Close() error
- }
再看net.Conn接口:
我们使用了gosshtool的NewSSHClient方法创建了一个客户端,并调用Connect()建立了连接,最后使用了Pipe(conn, pty, nil, 30)方法创建了一个保持会话,这样就号好了。这一切看起来如此简单,都要归功于Pipe这个方法,它的第一个参数是一个ReadWriteCloser接口类型,只要实现了该接口的结构都可以传入,这里我们会使用TCPConn这个结构,该结构实现了net.Conn接口,而net.Conn接口也是实现了ReadWriteCloser接口的。这个参数非常重要,我们建立了连接后,后续的通信全靠它了。你如果熟悉ReadWriteCloser接口,其实你就知道这个接口又组合了三个接口:
- type ReadWriteCloser interface {
- Reader
- Writer
- Closer
- }
-
- type Writer interface {
- Write(p []byte) (n int, err error)
- }
-
- type Reader interface {
- Read(p []byte) (n int, err error)
- }
-
- type Closer interface {
- Close() error
- }
再看net.Conn接口:
- type Conn interface {
- // Read从连接中读取数据
- // Read方法可能会在超过某个固定时间限制后超时返回错误,该错误的Timeout()方法返回真
- Read(b []byte) (n int, err error)
- // Write从连接中写入数据
- // Write方法可能会在超过某个固定时间限制后超时返回错误,该错误的Timeout()方法返回真
- Write(b []byte) (n int, err error)
- // Close方法关闭该连接
- // 并会导致任何阻塞中的Read或Write方法不再阻塞并返回错误
- Close() error
- // 返回本地网络地址
- LocalAddr() Addr
- // 返回远端网络地址
- RemoteAddr() Addr
- // 设定该连接的读写deadline,等价于同时调用SetReadDeadline和SetWriteDeadline
- // deadline是一个绝对时间,超过该时间后I/O操作就会直接因超时失败返回而不会阻塞
- // deadline对之后的所有I/O操作都起效,而不仅仅是下一次的读或写操作
- // 参数t为零值表示不设置期限
- SetDeadline(t time.Time) error
- // 设定该连接的读操作deadline,参数t为零值表示不设置期限
- SetReadDeadline(t time.Time) error
- // 设定该连接的写操作deadline,参数t为零值表示不设置期限
- // 即使写入超时,返回值n也可能>0,说明成功写入了部分数据
- SetWriteDeadline(t time.Time) error
- }
对比下会发现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客户端,如果基于现成的工具包确实没多少工作量,而且大部分功能都实现比较粗糙,权当抛砖引玉。
文档信息
有疑问加站长微信联系(非本文作者)