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实现以下功能:
- 让SSH-Server使用Github SSH 公钥用户身份Authentication.
- 让SSH-Server实现IM聊天的功能.
- 让SSH-Server查询股票价格.
- 让SSH-Server做文本翻译.
当然你可以实现action hook interface
来开发更加强大的功能.
3. 代码设计
RFC标准将SSH架构分成三部分(如上图所示):传输层协议,用户认证协议,连接协议.
- 传输层协议SSH Transport Layer Protocol:它负责认证服务器,加密数据,确保数据完整性, 虽然它运行在TCP之上,但其实它可以运行在任意可靠的数据流之上;
- 用户认证协议SSH User Authentication Protocol:它负责认证使用者是否是ssh服务器的用户, Public Key Authentication登陆ssh就将在这一层实现;
- 连接协议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
用户关系,群,聊天记录的modelmain.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用户认证协议流程
- 客户端向服务器端发送认证请求,认证请求中包含用户名,认证方法,与该认证方法相关的内容(如:password认证时,内容为密码).
- 服务器端对客户端进行认证,如果认证失败,则向客户端发送认证失败消息,其中包含可以再次认证的方法列表.
- 客户端从认证方法列表中选取一种认证方法再次进行认证.
- 该过程反复进行,直到认证成功或者认证次数达到上限,服务器关闭连接为止.
SSH提供多种认证方式:
- password认证:客户端向服务器发出 password认证请求,将用户名和密码加密后发送给服务器;服务器将该信息解密后得到用户名和密码的明文,与设备上保存的用户名和密码进行比较,并返回认证成功或失败的消息.
- publickey认证:采用数字签名的方法来认证客户端.目前,设备上可以利用RSA和 DSA两种公共密钥算法实现数字签名.客户端发送包含用户名,公共密钥和公共密钥算法的 publickey 认证请求给服务器端.服务器对公钥进行合法性检查,如果不合法,则直接发送失败消息;否则,服务器利用数字签名对客户端进行认证,并返回认证成功或失败的消息
- 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命令和scpclient_handle_sftp.go
: 处理subsystem
主要完成sftp的功能 我们在后面将重点介绍client.go
和client_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