熱身運動🏂
在開始之前,先來個熱身運動。雖然標題黨寫着快速打造一個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客戶端,如果基於現成的工具包確實沒多少工作量,而且大部分功能都實現比較粗糙,權當拋磚引玉。
文檔信息
- 版權聲明:自由轉載-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0
- 原文網址:http://www.cocosk.com/articles/2016⁄6/18/go-ssh-client-1.html
- 作者:卧雪Sirk