Go进阶52:开发扩展SSH的使用领域和功能

Go进阶52:开发扩展SSH的使用领域和功能

1. SSH

作为一名服务端开发,每天都会使用到SSH和terminal/console,我们从第一次接触服务器的时候就接触了SSH. 下面是SSH WIKI的关于SSH的解释:

Secure Shell(安全外壳协议,简称SSH)是一种加密的网络传输协议,可在不安全的网络中为网络服务提供安全的传输环境. SSH通过在网络中创建安全隧道来实现SSH客户端与服务器之间的连接.虽然任何网络服务都可以通过SSH实现安全传输, SSH最常见的用途是远程登录系统,人们通常利用SSH来传输命令行界面和远程执行命令.使用频率最高的场合类Unix系统.

既然SSH和HTTP,Websocket他们一样都是应通讯协议,为什么SSH仅仅局限于对服务器虚拟机的操作管理呢?

2. SSH可以更强大

线上体验LiveDemo ssh $YOUR_GITHUB_USER_NAME_OR_ANY@mojotv.cn

我们后端开发每天接触最多的是terminal,我们更加擅长编写命令行工具而不是GUI. 然而SSH和HTTP,Websocket他们一样都是通讯协议,为什么不把我们编写的命令行工具和SSH融合起来, 这样就可以让更多的人轻松方便的使用我们编写的Cloud命令行工具, 现在就开始行动,我们一起使用SSH协议来做一些不一样有趣的事情吧!

这篇文章中我们将开发一个SSH-Server实现以下功能:

  1. 让SSH-Server使用Github SSH 公钥用户身份Authentication.
  2. 让SSH-Server实现IM聊天的功能.
  3. 让SSH-Server查询股票价格.
  4. 让SSH-Server做文本翻译.

当然你可以实现action hook interface来开发更加强大的功能.

3. 代码设计

RFC标准将SSH架构分成三部分(如上图所示):传输层协议,用户认证协议,连接协议.

  1. 传输层协议SSH Transport Layer Protocol:它负责认证服务器,加密数据,确保数据完整性, 虽然它运行在TCP之上,但其实它可以运行在任意可靠的数据流之上;
  2. 用户认证协议SSH User Authentication Protocol:它负责认证使用者是否是ssh服务器的用户, Public Key Authentication登陆ssh就将在这一层实现;
  3. 连接协议SSH Connection Protocol:它将把多路(Multiplex)加密的通道转换成逻辑上的Channel.

同时SSH协议框架中还为许多高层的网络安全应用协议提供扩展的支持. 它们之间的层次关系可以用如上图来表示.

我们的代码将使用100% Golang编写,一个二进制可执行文件搞定全部平台 (One Golang executable binary rules them all).

项目代码目录结构:

.
├── action_default.go           //默认处理消息hook
├── action_friend_add.go        //添加好友hook
├── action_friend_list.go       //显示好友hook
├── action_help.go              //显示help
├── action_interface.go         //定义hook interface plugin
├── action_square.go            //公共频道聊天hook
├── action_stock.go             //显示A股股票价格hook
├── action_translate.go         //英文翻译中hook
├── client.go                   //用户session状态维护
├── client_handle_exec.go       //处理 ssh 远程执行 command,和 SCP
├── client_handle_sftp.go       //处理 SFTP
├── client_handle_shell.go      //处理 ssh 交互shell
├── db.sqlite3                  //sqlite3 数据库,可以更换其他数据库
├── hub_interface.go            //为将来分布扩展预留interface,将来聊天消息使用MQ Kafka
├── hub_msg_mem.go              //postOffice MQ golang chan 内存interface实现
├── hub_office_mem.go           //postOffice 好友群组关系db interface实现
├── main.go                     //项目入口
├── model_group.go              //模型:群组 用户关系
├── model_msg.go                //模型:消息
├── model_user.go               //模型:用户 好友关系
├── sshd_auth.go                //用户认证协议SSH User Authentication Protocol LDAP OAUTH2 google MFA ...
├── sshd_auth_permission.go     //用户认证之后信息传递
├── sshd_connection_protocol.go //连接协议SSH Connection Protocol
├── sshd_run.go                 //传输层协议SSH Transport Layer Protocol
└── util.go                     //帮助function

代码说明:这个项目是一个简单的Demo,采用超级扁平的代码目录结构,来简化代码逻辑,开发负责的应用你看按照文件名称的前缀来把代码拆分到不同的package.

  • sshd_auth***.go 用户认证协议SSH User Authentication Protocol: sshd用户认证校验,可以扩展 LDAP OAUTH2 google MFA …等用户体系
  • sshd_run.go 传输层协议SSH Transport Layer Protocol: main.go main函数执行的入口
  • sshd_connection_protocol.go 连接协议SSH Connection Protocol 代码入口
  • client*.go 应用层核心代码处理用户认证之后的各种逻辑
  • action_*.go 自定义Action Hook,实现interface 可以完成各种功能的扩展
  • hub_*.go 用户关系维护,聊天记录,数据来源,可以更换成其他的DB,可以把golang memory channel MQ更换成kafka… 来支持分布式系统
  • model_.go 用户关系,群,聊天记录的model
  • main.go 整个app的执行入口

3.1 Golang package

module sshimdemo //SSH-IM-Demo
go 1.15
require (
	github.com/fatih/color v1.10.0   
	github.com/mattn/go-runewidth v0.0.4 // indirect
	github.com/olekukonko/tablewriter v0.0.1 
	golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
	golang.org/x/term v0.0.0-20201117132131-f5c789dd3221
	golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2
	gorm.io/driver/sqlite v1.1.4
	gorm.io/gorm v1.20.11
)
  • github.com/fatih/color 在terminal中打印色彩
  • golang.org/x/text gbk utf8 编码转换
  • golang.org/x/term pty 来处理用户输入输出
  • gorm.io/gorm SQL数据库ORM

核心packagegolang.org/x/crypto/ssh 实现了SSH客户端和服务器.但是我们在这里主要使用他们的server端函数方法. 如果你想开发一个替代python ansible的Golang轮子,你一定为用到 golang.org/x/crypto/ssh 的客户端代码.

SSH是传输安全协议,身份验证协议和一系列应用程序协议.它专门实现了应用程序级别协议是远程shell. SSH的多路复用multiplexed特性也提供需要的开发者.

3.2 Golang SSH 传输层协议编码

sshd_run.go

package main

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

var privateBytes = []byte(`
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
`) // sshd-server 的私钥正式

// startSshSvrListen 启动ssh-server服务 db来管理 用户登录 和 用户登录日志 完成一些聊天的功能
// 这个方法将被 main.go 的 main方法调用,ssh-server的启动入口
func startSshSvrListen(addr string, db *gorm.DB) {
	//初始话 ssh-server 客户端的配置, 用户认证
	config := &ssh.ServerConfig{
		NoClientAuth: false, //如果是true ssh-sever不需要用户认证
		MaxAuthTries: 6,     //用户认证重试次数
		//PasswordCallback:            authUserMfa(db),       //ssh用户名密码认证 可以扩展成  LDAP ... google MFA
		PublicKeyCallback:           authPublicKeysOfGithub(db), //github用户ssh公钥登录 https://github.com/${githubUserName}.keys  https://github.com/mojocn.keys
		KeyboardInteractiveCallback: authKeyboard(db),           //键盘问答输入用户认证 可以扩展成  LDAP ... google MFA
		AuthLogCallback:             nil,                        //记录用户登录认证日志的callback
		ServerVersion:               "",                         //"ssh可以扩展的更多功能的聊天服务", ascii string only  //自定义服务端的版本信息
	}
	// 解析需要给服务端设置ssh 私钥
	private, err := ssh.ParsePrivateKey(privateBytes)
	if err != nil {
		log.Fatal("无效私钥证书:", err)
	}
	config.AddHostKey(private)

	//监听socket
	listener, err := net.Listen("tcp", addr)
	if err != nil {
		log.Fatal("启动socket失败::", err)
	}
	for {
		// 处理连接
		conn, err := listener.Accept()
		if err != nil {
			// handle error
			log.Println(err)
			continue
		}
		// 用户认证协议SSH User Authentication Protocol
		// 开始工作
		// 开始 handshake 用户登录之前这里用户身份认证, ssh.NewServerConn 会调用上面 PasswordCallback  PublicKeyCallback KeyboardInteractiveCallback ...的callback
		// 用户登录成之后需要向后传递的参数可以 从 sConn.Permissions 中获取
		sConn, chans, reqs, err := ssh.NewServerConn(conn, config)
		if err != nil {
			// handle error
			log.Print(err)
			continue
		}
		//处理 连接协议SSH Connection Protocol
		// 用户handshake 认证成功
		// 强制必须 丢弃服务的request,防止被攻击
		go ssh.DiscardRequests(reqs)
		// 核心/VIP/MVP 处理连接协议SSH Connection Protocol
		go handleChannels(chans, sConn)
	}
}

3.3 Golang SSH 用户认证协议编码

SSH用户认证协议流程

  1. 客户端向服务器端发送认证请求,认证请求中包含用户名,认证方法,与该认证方法相关的内容(如:password认证时,内容为密码).
  2. 服务器端对客户端进行认证,如果认证失败,则向客户端发送认证失败消息,其中包含可以再次认证的方法列表.
  3. 客户端从认证方法列表中选取一种认证方法再次进行认证.
  4. 该过程反复进行,直到认证成功或者认证次数达到上限,服务器关闭连接为止.

SSH提供多种认证方式:

  1. password认证:客户端向服务器发出 password认证请求,将用户名和密码加密后发送给服务器;服务器将该信息解密后得到用户名和密码的明文,与设备上保存的用户名和密码进行比较,并返回认证成功或失败的消息.
  2. publickey认证:采用数字签名的方法来认证客户端.目前,设备上可以利用RSA和 DSA两种公共密钥算法实现数字签名.客户端发送包含用户名,公共密钥和公共密钥算法的 publickey 认证请求给服务器端.服务器对公钥进行合法性检查,如果不合法,则直接发送失败消息;否则,服务器利用数字签名对客户端进行认证,并返回认证成功或失败的消息
  3. keyboardInteractive认证: 可应定义多种keyboard-challenge来提示用户输入认证的input.

sshd_auth.go

package main

import (
	"bytes"
	"context"
	"crypto/md5"
	"errors"
	"fmt"
	"github.com/sirupsen/logrus"
	"golang.org/x/crypto/ssh"
	"gorm.io/gorm"
	"io/ioutil"
	"net/http"
	"strings"
	"time"
)
//authPublicKeysOfGithub github.com 公钥身份authentication
func authPublicKeysOfGithub(db *gorm.DB) func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
	return func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
		userName := conn.User()
		err := sshPublicKeysAuthByGithub(userName, key)//用过github.com api 获取用户名的公钥 校验
		if err != nil {
			return nil, err
		}
		one := new(User)
		err = db.Where("name = ?", userName).Take(one).Error
		if errors.Is(err, gorm.ErrRecordNotFound) {
			one.Name = userName
			err := db.Save(one).Error
			if err != nil {
				logrus.Error(err)
			}
		}
		return setPermission(one, Fingerprint(key), "github"), nil
	}
}
//authKeyboard 用户普通匿名登录 记录用户信息
func  authKeyboard(db *gorm.DB) func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
	return func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
		userName := conn.User()
		one := new(User)
		err := db.Where("name = ?", userName).Take(one).Error
		if errors.Is(err, gorm.ErrRecordNotFound) {
			one.Name = userName
			err := db.Save(one).Error
			if err != nil {
				logrus.Error(err)
			}
		}
		return setPermission(one, "", "anon"), nil
	}
}


//Fingerprint 计算公钥指纹
func Fingerprint(k ssh.PublicKey) string {
	hash := md5.Sum(k.Marshal())
	r := fmt.Sprintf("% x", hash)
	return strings.Replace(r, " ", ":", -1)
}
//sshPublicKeysAuthByGithub 比较github的公钥
func sshPublicKeysAuthByGithub(user string, key ssh.PublicKey) error {
	publicKeys, err := fetchGithubPublicKeys(user)
	if err != nil {
		return err
	}
	for _, pbk := range publicKeys {
		if bytes.Equal(key.Marshal(), pbk.Marshal()) {
			return nil
		}
	}
	return fmt.Errorf("the key is not match any https://github.com/%s.keys", user)
}
//fetchGithubPublicKeys 获取当用户名的公钥
func fetchGithubPublicKeys(githubUser string) ([]ssh.PublicKey, error) {
	keyURL := fmt.Sprintf("https://github.com/%s.keys", githubUser)
	ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*15)
	defer cancelFunc()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, keyURL, nil)
	if err != nil {
		return nil, err
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode != 200 {
		return nil, errors.New("invalid response from github")
	}
	authorizedKeysBytes, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return nil, fmt.Errorf("reading body:%v", err)
	}
	var keys []ssh.PublicKey
	for len(authorizedKeysBytes) > 0 {
		pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(authorizedKeysBytes)
		if err != nil {
			return nil, fmt.Errorf("parsing key: %v",err)//errors.Wrap(err, "parsing key")
		}
		keys = append(keys, pubKey)
		authorizedKeysBytes = rest
	}
	return keys, nil
}

因为篇幅原因,源文件中还有其他认证方式的实现.注释的代码中包含实现 MFA身份认证,自定义键盘交互输入身份认真信息 和 用户名密码用户认证的实例代码. 这部分代码可以可以作为实现其他认证方式的参考.

3.4 Golang SSH 连接协议编码

在认证完毕后,客户端和服务端之间将使用SSH连接协议进行实际的任务操作,包括开启交互式的登录会话, 远程命令调用,TCP转发,X11转发等.在传输层协议之上,启用连接协议的方式就是请求一个service name为ssh-connection服务.

3.4.1 SSH Channel机制

连接协议里的每个实际应用都是Channel,各方都有可能打开Channel, 大量的Channel复用同一个Connection(我认为这里指的Connection应该是上文说的ssh-connection service). 一个Channel被双方用自己的数字标识,所以每端不同的数字可能指向的并不是相同的Channel. 其他任何和Channel相关的消息都会包含对端的Channel标识.

3.4.2 sshd_connection_protocol.go 代码解析

这部分代码是处理SSH连接协议(SSH Connection Protocol) 的入口.

package main

import (
	"fmt"
	"github.com/sirupsen/logrus"
	"golang.org/x/crypto/ssh"
	"golang.org/x/term"
	"log"
)

func  handleChannels(channels <-chan ssh.NewChannel, sshConn *ssh.ServerConn) {
	user, err := getPermissionUser(sshConn)// 获取  SSH User Authentication Protocol 传递过来的认证用户信息
	if err != nil {
		log.Println(err)
		return
	}
	//创建session pty client
	c := NewClient(user, sshConn, postManVar)
	promptString := fmt.Sprintf("[%s] ", user.Name)

	hasShell := false

	for ch := range channels {
		//交互Session
		//一个Session就是一个远程的程序的执行.这个程序或许是shell,应用程序,系统调用或者内建的子系统.它可能没有绑定到虚拟终端上,又或者有或没有涉及到X11转发.同时间,可以有多个Session正在被运行.
		if t := ch.ChannelType(); t != "session" {
			ch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t))
			continue
		}

		channel, requests, err := ch.Accept()
		if err != nil {
			log.Printf("Could not accept channel: %v", err)
			continue
		}
		defer channel.Close()

		c.Term = term.NewTerminal(channel, promptString)

		for req := range requests {
			var width, height int
			var ok bool
			switch req.Type {
			case "shell":
				// 开启交互 tty shell
				//一旦一个Session被设置完毕,在远端就会有一个程序被启动.这个程序可以是一个Shell,也可以时一个应用程序或者是一个有着独立域名的子系统.
				if c.Term != nil && !hasShell {
					go c.HandleShell(channel)
					ok = true
					hasShell = true
				}
			case "pty-req"://通过如下消息可以让服务器为Session分配一个虚拟终端
				//当客户端的终端窗口大小被改变时,或许需要发送这个消息给服务器.
				width, height, ok = parsePtyRequest(req.Payload)
				if ok {
					err := c.Resize(width, height)
					ok = err == nil
				}
			case "window-change":
				//客户terminal size 改变 client.Term (pty) 的size也需要改变,否则console输出会出现排版错误
				width, height, ok = parseWinchRequest(req.Payload)
				if ok {
					err := c.Resize(width, height)
					ok = err == nil
				}
			case "exec":
				// ssh root@mojotv.cn whoami
				//一旦一个Session被设置完毕,在远端就会有一个程序被启动.这个程序可以是一个Shell,也可以时一个应用程序或者是一个有着独立域名的子系统.
				command, err := c.ParseCommandLine(req)// 协议 req.Payload 里面的用户命令输出
				if err != nil {
					logrus.Printf("error parsing ssh execMsg: %s\n", err)
					return
				} else {
					ok = true
				}
				//开始执行从 whoami 远程shell 命令
				// 执行完成 结果直接返回
				go c.HandleExec(command, channel)
			case "env":
				//在shell或command被开始时之后,或许有环境变量需要被传递过去.然而在特权程序里不受控制的设置环境变量是一个很有风险的事情,
				//所以规范推荐实现维护一个允许被设置的环境变量列表或者只有当sshd丢弃权限后设置环境变量.
				//todo set language i18n
				logrus.Info(string(req.Payload))
			case "subsystem":
				//一旦一个Session被设置完毕,在远端就会有一个程序被启动.这个程序可以是一个Shell,也可以时一个应用程序或者是一个有着独立域名的子系统.
				// 实现一下功能可以实现 sftp功能
				//fmt.Fprintf(debugStream, "Subsystem: %s\n", req.Payload[4:])
				if string(req.Payload[4:]) == "sftp" {
					ok = true
					go c.HandleSftp(channel)
				}

			default:
				log.Println(req.Type, string(req.Payload))
			}
			if req.WantReply {
				req.Reply(ok, nil)
			}
		}
	}
}

request type "exec" "subsystem" "env" 这篇文章就不重点介绍了,感兴趣请直接查看源代码.

  • client_handle_exec.go : 处理exec 执行 远程shell命令和scp
  • client_handle_sftp.go : 处理subsystem 主要完成sftp的功能 我们在后面将重点介绍client.goclient_handle_shell.go.

3.5 Client Session 状态维护

3.5.1 client.go


package main

import (
	"fmt"
	"github.com/fatih/color"
	"github.com/sirupsen/logrus"
	"golang.org/x/crypto/ssh"
	"golang.org/x/term"
	"time"
)

type Client struct {
	DeviceSessionID string //设备uuid
	Conn            *ssh.ServerConn
	postman         *PostMam //处理 用户关系和 消息msg MQ
	User            *User//当前用户
	selectedFriend  *User//当前窗口选择的对话的好友 可以是nil
	selectedGroup   *Group//当前窗口选择的对话的群组 可以是nil
	Term            *term.Terminal
	termWidth       int
	termHeight      int
}

// NewClient constructs a new client
// 1.记录client terminal的状态
// 2.当前用户的状态
// 3.消息发送接收
// 4.好友群组关系管理
// 5.读取客户段terminal的输入
func NewClient(user *User, conn *ssh.ServerConn, pm *PostMam) *Client {

	return &Client{
		DeviceSessionID: string(conn.SessionID()),
		Conn:            conn,
		postman:         pm,
		User:            user,
		selectedFriend:  nil,
		selectedGroup:   nil,
		Term:            nil,//pty
		termWidth:       0,
		termHeight:      0,
	}
}

// TermWrite 写入消息到当强用户ssh 客户端
func (c *Client) writeBack(msg string) {
	c.Term.Write([]byte(msg))
}


func (c *Client) PromptHome() {
	c.Term.SetPrompt(fmt.Sprintf("[%s]", "🌏"))
}
func (c *Client) SetPrompt(s string) {
	c.Term.SetPrompt(fmt.Sprintf("[%s]", s))
}

func (c *Client) Danger(msg string) {
	content := color.RedString("🔴  %s\r\n", msg)
	c.writeBack(content)
}
func (c *Client) Warning(msg string) {
	content := color.YellowString("🟠  %s\r\n", msg)
	c.writeBack(content)
}

func (c *Client) Success(msg string) {
	content := color.GreenString("🟢  %s\n", msg)
	c.writeBack(content)
}

func (c *Client) Primary(msg string) {
	content := color.BlueString("🔵  %s\r\n", msg)
	c.writeBack(content)
}

func (c *Client) MsgPrivate(msg string) {
	content := color.HiCyanString("💬  %s\r\n", msg)
	c.writeBack(content)
}

func (c *Client) MsgGroup(msg string) {
	content := color.HiYellowString("📻 %s\r\n", msg)
	c.writeBack(content)
}
func (c *Client) WritePigeonMsg(msg Msg) {
	if msg.GroupID > 0 {
		c.MsgGroup(msg.Content + "\r\n")
	} else {
		c.MsgPrivate(msg.Content + "\r\n")
		return
	}
}


// Resize resizes the client to the given width and height
func (c *Client) Resize(width, height int) error {
	width = 1000000 //
	err := c.Term.SetSize(width, height)
	if err != nil {
		logrus.Errorf("Resize failed: %dx%d", width, height)
		return err
	}
	c.termWidth, c.termHeight = width, height
	return nil
}

func (c *Client) setSessionPrompt() {
	prompt := fmt.Sprintf("[%s]", c.User.Name)
	if c.selectedFriend != nil {
		prompt = fmt.Sprintf("[%s -> %s]", c.User.Name, c.selectedFriend.Name)
	}
	if c.selectedGroup != nil {
		prompt = fmt.Sprintf("[%s IN %s]", c.User.Name, c.selectedGroup.Name)
	}
	c.Term.SetPrompt(prompt)
}

3.5.2 client_handle_shell.go

package main

import (
	"github.com/sirupsen/logrus"
	"golang.org/x/crypto/ssh"
	"strings"
)

//HandleShell 这里将真这的处理用户 ssh shell 输入
func (c *Client) HandleShell(channel ssh.Channel) {
	defer channel.Close()
	exitChan := make(chan bool, 1)
	//c.Server.Add(c)
	// 用户进入聊天界面 开始注册用户在线状态, 聊天消息的队列
	err := c.postman.RegisterClientDevice(c.User, c.DeviceSessionID)
	if err != nil {
		logrus.Println(err)
		return
	}
	go func() {
		// Block until done, then remove.
		c.Conn.Wait()
		c.closed = true
		//c.Server.Remove(c)
		//close(c.Messages)
		//c.postman.UserOffline(c.User)  // 用户离线
	}()

	go func() {
		//todo:: send history msg

		// 接受其他用户发送给你的消息 或 广播消息
		c.postman.ReceiveMsgLoop(c.DeviceSessionID, c, exitChan)
	}()
	new(actionHelp).Exec(c,nil) //输出帮助信息
	for {
		line, err := c.Term.ReadLine()
		if err != nil {
			break
		}
		// 使用 默认的 hook action 来处理 交互shell的键盘输入(聊天 或者 指令)
		var doer ActionDoer = new(ActionDefault)
		// choose action
		isCmd, action, args := parseInputLine(line) // 解析用户输入 return 是否是指令 or 是发送聊天消息
		if isCmd {
			v, ok := ActionMap[action] //开始匹配指令 有点类似与gin中的路由匹配
			if ok {
				doer = v // 匹配指令的 hook
			} else {
				c.Danger("未知动作指令: " + line)
				continue
			}
		}
		//
		if hint := doer.Hint(args); hint != "" { //参数检查
			c.Warning("Invalid command: " + line)
			continue
		}
		err = doer.Exec(c, args) // 执行自定义的action hook
		if err != nil {
			c.Warning(err.Error())
			logrus.Error(err)
			//c.TermWrite(err.Error())
		}
	}

}
//parseInputLine 解析input
func parseInputLine(line string) (isCmd bool, action string, args []string) {
	parts := strings.Split(line, " ")
	if len(parts) > 0 && strings.HasPrefix(parts[0], "/") {
		args = []string{}
		for _, p := range parts {
			if t := strings.TrimSpace(p); t != "" {
				args = append(args, t)
			}
		}
		return true, strings.TrimPrefix(args[0], "/"), args
	}
	return false, "", []string{line}
}

3.6 Hook 扩展功能

你只需要编写实现一下interface的method并在 init函数 registerAction 就可以完成hook的完成你开发的扩展.

action_interface.go

package main

import "log"


var ActionMap = map[string]ActionDoer{}

//ActionDoer 编写插件hook 来扩展更多的功能
type ActionDoer interface {
	Help() (alias, long string) //命令扩展的帮助信息
	Hint(args []string) string //exec 之前的参数检查
	Exec(c *Client, args []string) error //扩展的执行逻辑
}

//registerAction 注册编写的action hook 扩展功能,这个方法建议在 init 函数中调用
func registerAction(name string, doer ActionDoer) {
	_, ok := ActionMap[name]
	if ok {
		log.Fatal("action has already existed: ", name)
	} else {
		ActionMap[name] = doer
	}
}

3.7 Hook:IM聊天功能

  • action_default.go 处理默认聊天或命令操作
  • action_friend_add.go 添加好友
  • action_friend_list.go 显示好友和选择好友对话
  • action_default.go 发送消息给好友或者给群组
  • action_square.go 广场公共聊天

当然以上聊天功能离不开 PostOffice interface 实现的用户关系管理和MQ消息队列. 由于以上代码过于多这里不做过多解读.请详细查看源代码.

3.8 Hook:A股股票价格

package main

import (
	"bytes"
	"fmt"
	"github.com/olekukonko/tablewriter" //在terminal中打印出漂亮的表格
	"golang.org/x/text/encoding/simplifiedchinese"
	"golang.org/x/text/transform"
	"io/ioutil"
	"net/http"
	"strings"
)

func init() {
	registerAction("stock", new(ActionStock))
}

type ActionStock struct{}

func (a ActionStock) Help() (alias, log string) {
	return "stock", "输入股票代码查询股票价格 eg: /stock sh688111 sh600036 sz002594"
}
func (a ActionStock) Exec(c *Client, args []string) error {
	var headers []string
	for _, v := range template {
		headers = append(headers, v.Desc)
	}
	table := tablewriter.NewWriter(c.Term)
	//设置表格样式
	table.SetHeader(headers)
	table.SetBorder(true)
	table.SetHeaderColor(
		tablewriter.Colors{tablewriter.FgHiBlueColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgWhiteColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgCyanColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgHiRedColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgMagentaColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgGreenColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgYellowColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgYellowColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgYellowColor, tablewriter.Bold},
		tablewriter.Colors{tablewriter.FgYellowColor, tablewriter.Bold},

	)
	table.SetColumnColor(
		tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlueColor},
		tablewriter.Colors{tablewriter.Normal, tablewriter.FgWhiteColor},

		tablewriter.Colors{tablewriter.Normal, tablewriter.FgCyanColor},
		tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiRedColor},
		tablewriter.Colors{tablewriter.Normal, tablewriter.FgMagentaColor},
		tablewriter.Colors{tablewriter.Normal, tablewriter.FgGreenColor},
		tablewriter.Colors{tablewriter.Normal, tablewriter.FgYellowColor},
		tablewriter.Colors{tablewriter.Normal, tablewriter.FgYellowColor},
		tablewriter.Colors{tablewriter.Normal, tablewriter.FgYellowColor},
		tablewriter.Colors{tablewriter.Normal, tablewriter.FgYellowColor},
	)
	var err error
	for _,code :=range args[1:]{
		row, err2 := stockPrice(code)//sina api 获取股票价格
		if err2 != nil {
			err = err2
		}
		table.Append(row)// 插入股票信息到表格
	}
	table.Render()
	table = nil
	return err
}

func (a ActionStock) Hint(args []string) string {
	return ""
}

var template = []StockItem{
	{
		Idx:   0,
		Desc:  "Name",
		Value: "",
	},
	{
		Idx:   1,
		Desc:  "Today_Start_Price",
		Value: "",
	},
	{
		Idx:   2,
		Desc:  "Yesterday_End_Price",
		Value: "",
	},
	{
		Idx:   3,
		Desc:  "Current_Price",
		Value: "",
	},
	{
		Idx:   4,
		Desc:  "Today_Top",
		Value: "",
	},
	{
		Idx:   5,
		Desc:  "Today_Bottom",
		Value: "",
	},
	{
		Idx:   6,
		Desc:  "Buy_One",
		Value: "",
	},
	{
		Idx:   7,
		Desc:  "Sell_One",
		Value: "",
	},
	{
		Idx:   8,
		Desc:  "Deal_Amount",
		Value: "",
	},
	{
		Idx:   9,
		Desc:  "Deal_Money",
		Value: "",
	},
}

type StockItem struct {
	Idx   int
	Desc  string
	Value string
}


func GbkToUtf8(s []byte) ([]byte, error) {
	reader := transform.NewReader(bytes.NewReader(s), simplifiedchinese.GBK.NewDecoder())
	d, e := ioutil.ReadAll(reader)
	if e != nil {
		return nil, e
	}
	return d, nil
}

func Utf8ToGbk(s []byte) ([]byte, error) {
	reader := transform.NewReader(bytes.NewReader(s), simplifiedchinese.GBK.NewEncoder())
	d, e := ioutil.ReadAll(reader)
	if e != nil {
		return nil, e
	}
	return d, nil
}


func stockPrice(stockCode string) (list []string, err error) {
	url := fmt.Sprintf("http://hq.sinajs.cn/list=%s", stockCode)
	resp, err := http.Get(url)
	if err != nil {
		return nil, err
	}
	bs, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	utf8, err := GbkToUtf8(bs)
	if err != nil {
		return nil, err
	}
	parts := strings.Split(string(utf8), `="`)
	body := parts[1]

	ps := strings.Split(body, ",")

	for _, v := range template {
		list = append(list, ps[v.Idx])
	}

	return

}

4 build & run

线上体验LiveDemo ssh $YOUR_GITHUB_USER_NAME_OR_ANY@mojotv.cn

防止sshd mojotv.cn被劫持,请确保一下RSA密钥指纹信息如下.

The authenticity of host 'mojotv.cn (39.106.87.48)' can't be established.
RSA key fingerprint is SHA256:QLNi0/fJsotNS++3b4vqiKyAMl5mAz/xkorB7aCIuFQ.

local development: 执行 go run main.go 在你的终端中输入 ssh -p 2222 $YOUR_GITHUB_USER_NAME_OR_ANY@localhost

5. 参考资料

  • The Secure Shell (SSH) Protocol Architecture https://tools.ietf.org/html/rfc4251
  • The Secure Shell (SSH) Authentication Protocol https://tools.ietf.org/html/rfc4252
  • The Secure Shell (SSH) Transport Layer Protocol https://tools.ietf.org/html/rfc4253
  • The Secure Shell (SSH) Connection Protocol https://tools.ietf.org/html/rfc4254
  • golang.org/x/crypto/ssh https://pkg.go.dev/golang.org/x/crypto/ssh
  • Go进阶35:Go语言自定义自己的SSH-Server
  • golang-ssh-01:执行远程命令
  • Live Demo ssh eric@mojotv.cn ssh felix@mojotv.cn
目录