轻量级ssh管理工具--tssh

消失了一阵子,主要是因为搬砖的工作实在太忙,没有时间发挥我的天性,写点沙雕blog

sorry

最近给自己写了一个ssh工具,属实是希望好好推广一下

求求你

地址 https://github.com/luanruisong/tssh

欢迎小伙伴们下载使用

如果喜欢的话,记得 star,fork,watch 三连击

数据够了咱就上homebrew

接下来咱们就聊一聊做这个玩意的心路历程

给自己立需求

在最近的工作当中,碰到了一些问题。

主要是因为我习惯使用的 命令行ssh工具,不能存储密码导致的

以前呢,我都是使用shell自己维护一个密码文件夹,然后调用expect脚本来进行password的识别以及密码输入。

大概是这个样子

spawn ssh $user@$host
expect {
    "*password:" { send "$passwd\r" }
    "yes/no" { send "yes\r";exp_continue }
}
interact

但是最近越看越不顺眼,尤其是拙劣的文本读取,过滤等操作

没眼看

所以准备拿go直接实现一个我想要的样子。

给自己确定的需求我准分两大部分

  1. 基础需求
    1. 实现ssh登录
    2. 实现多种方式登录
  2. 支撑需求
    1. 提供命令行参数维护需要链接的服务器

开搞

基于以上两大部分,我再给细化成几个小点

需求已经确定了,那我们现在开搞

搞事情

支撑需求

支撑需求比较简单,基本上都是一些io的处理,所以我们先搞定这部分

先定义一个结构体用于处理我们保存的服务器信息

type SSHConfig struct {
    Name   string
    Ip     string
    User   string
    Pwd    string
    SshKey string
    Port   int
    SaveAt string
}

里面有几个比较重要的函数

func (s *SSHConfig) SaveToPath(path string) error {
    b, e := json.MarshalIndent(s, "", " ")
    if e != nil {
        return e
    }
    err := ioutil.WriteFile(path, b, os.ModePerm)
    if err == nil {
        fmt.Println("save", s.Name, " success")
    }
    return err
}

func GetFromPath(path string) (s *SSHConfig, e error) {
    var b []byte
    b, e = ioutil.ReadFile(path)
    if e != nil {
        return
    }
    s = &SSHConfig{}
    e = json.Unmarshal(b, s)
    return
}

这部分很简单,基本上就是把我们拿到的服务器信息,使用json的形式保存再磁盘的一个路径上。

我们再定义一个环境变量

const EnvName = "TSSH_HOME"

这样其实我们的程保存的信息基本上都会保存在环境变量的TSSH_HOME里面

加上一个环境变量检测函数

func DefaultCheck() error {
    configPath = os.Getenv(EnvName)
    if len(configPath) == 0 {
        return fmt.Errorf("env '%s' not found,please set a dir in env", EnvName)
    }

    if !fileExists(configPath) {
        return os.MkdirAll(configPath, os.ModePerm)
    }
    return nil
}

下面是存储包对外开放的函数接口


func ConfigExists(name string) bool {
    return fileExists(path.Join(configPath, name))
}

func Get(name string) (*SSHConfig, error) {
    finalPath := path.Join(configPath, name)
    if !fileExists(finalPath) {
        return nil, fmt.Errorf("config %s not exists", name)
    }
    return GetFromPath(finalPath)
}

func Del(name string) error {
    finalPath := path.Join(configPath, name)
    if !fileExists(finalPath) {
        return fmt.Errorf("config %s not exists", name)
    }
    err := os.Remove(finalPath)
    if err == nil {
        fmt.Println("delete", name, "success")
    }
    return err
}

func Set(cfg *SSHConfig) error {
    finalPath := path.Join(configPath, cfg.Name)
    if fileExists(finalPath) {
        _ = os.Remove(finalPath)
    }
    return cfg.SaveToPath(finalPath)
}

func List() ([]SSHConfig, error) {
    dir, err := ioutil.ReadDir(configPath)
    if err != nil {
        return nil, err
    }
    res := make([]SSHConfig, 0)
    for _, v := range dir {
        cfg := SSHConfig{}
        b, e := ioutil.ReadFile(path.Join(configPath, v.Name()))
        if e != nil {
            return nil, e
        }
        if e = json.Unmarshal(b, &cfg); err == nil {
            res = append(res, cfg)
        } else {
            return nil, e
        }
    }
    return res, nil
}

func Env() {
    fmt.Println("env", EnvName, "=", configPath)
}

至此,支撑工作基本上就完成了

基础需求

简单的做完了,剩下的就是这一块最难啃的骨头了

啃骨头

这里我们采用了golang的 x/crypto/ssh 包来进行ssh链接

根据auth方式的不通创建config的代码


func PwdCfg(user, pwd string) *ssh.ClientConfig {
    return &ssh.ClientConfig{
        User: user,
        Auth: []ssh.AuthMethod{ssh.Password(pwd)},
        HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
            return nil
        },
        Timeout: 10 * time.Second,
    }
}

func PkCfg(user, pkPath string) (*ssh.ClientConfig, error) {
    pemBytes, err := ioutil.ReadFile(pkPath)
    if err != nil {
        return nil, fmt.Errorf("Reading private key file failed %v", err)
    }

    signer, err := ssh.ParsePrivateKey(pemBytes)
    if err != nil {
        return nil, fmt.Errorf("Parsing plain private key failed %v", err)
    }
    return &ssh.ClientConfig{
        User: user,
        Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
        HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
            return nil
        },
        Timeout: 10 * time.Second,
    }, nil
}

根据IP,Config等信息,创建链接的函数

func Connect(ip string, port int, cfg *ssh.ClientConfig) (*ssh.Client, error) {
    addr := fmt.Sprintf("%s:%d", ip, port)
    sshClient, err := ssh.Dial("tcp", addr, cfg)
    if err != nil {
        return nil, err
    }
    return sshClient, nil
}

事情到了这里,感觉一切都顺利的有些过分,然而我眉头轻轻一皱,发现事情并没有那么简单

没有那么简单

因为在链接ssh之后,我们采用了ssh/terminal包来获取一些当前窗口的信息

导致在创建的session中出现了两个问题

首先 ssh/terminal 获取fd 以及宽高的时候,再windows下并不兼容,轻则报错,重则panic

panic

其次就是采用了VT100指令集,这里就算成功拿到宽高,VT100的tab等命令回写,再windows里看起就像是乱码

所以以我本人的一己之力,成功的让go的跨平台成为了一个笑话

可笑

拉来帮我测试windows的小伙伴,也用一种神奇的目光看着我。

所以我找了个时间,再windows环境下集中的测试了一下,发现这个鬼包就是不支持windows啊

哭

就在此时,我再stackoverflow上面发现了一个神奇的包 containerd/console

这个包完全实现了我需要的目的,通过使用编译选项的方式解决了我跨平台时终端大小获取的问题

同时也成功的在windows下采用了他该用的指令集(虽然我不知道是啥,但不是VT100)

所以,补全了我代码拼图的最后一块

func RunTerminal(c *ssh.Client, in io.Reader, stdOut, stdErr io.Writer) error {
    session, err := c.NewSession()
    if err != nil {
        return err
    }
    defer session.Close()
    session.Signal(ssh.SIGINT)
    session.Stdout = stdOut
    session.Stderr = stdErr
    session.Stdin = in
    var (
        current = console.Current()
        ws      console.WinSize
    )
    defer current.Reset()

    if err = current.SetRaw(); err != nil {
        return err
    }

    if ws, err = current.Size(); err != nil {
        return err
    }

    // Set up terminal modes
    modes := ssh.TerminalModes{
        ssh.ECHO:          1,     //打开回显
        ssh.TTY_OP_ISPEED: 14400, //输入速率 14.4kbaud
        ssh.TTY_OP_OSPEED: 14400, //输出速率 14.4kbaud
        ssh.VSTATUS:       1,
    }

    //Request pseudo terminal
    if err = session.RequestPty("xterm-256color", int(ws.Height), int(ws.Width), modes); err != nil {
        return err
    }

    if err = session.Shell(); err != nil {
        return err
    }
    return session.Wait()
}

至此,我们给自己定义的所有需求,都完全搞定

整合需求

需求都弄完了,我们现在要搞个main文件来规定一下入口及参数

这里我们是用的flag包,毕竟简单,原生支持这两点已经无需多说了

if err := store.DefaultCheck(); err != nil {
    fmt.Println(err)
    return
}

var (
    a = flag.String("a", "", "add config {user@host}")
    s = flag.String("s", "", "set config {user@host}")
    d = flag.String("d", "", "del config {name}")
    c = flag.String("c", "", "connect config host {name}")
    l = flag.Bool("l", false, "config list")
    e = flag.Bool("e", false, "evn info")
    v = flag.Bool("v", false, "app version")
)

var (
    n = flag.String("n", "", "set name in (-a|-s)")
    p = flag.String("p", "", "set password in (-a|-s)")
    P = flag.Int("P", 22, "set port in (-a|-s)")
    k = flag.String("k", "", "set private_key path in (-a|-s)")
)

flag.Parse()

一键安装

写到这里,主体部分基本上已经都OK了

还剩下点边边角角,比如,如何让用户快速的安装?

常规想法,咱也上个homebrew吧,毕竟一键安装爽的很

但是,根据他们要求的格式,写好脚本,commit,发起pr之后

一个邮件让我坠入了深渊。。。。

fuck

好吧,star,fork,watch这种东西,还是比较随缘的,我们还是先考虑如何让用户便捷的直接安装吧。。

放弃

这里我们编写了一个 install.sh,并使用一个比较简单的方式来进行安装

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/luanruisong/tssh/master/install.sh)"

我们的 install.sh里面 基本上就包含了几个部分

先定义一下我用的一些参数

tag=/usr/local/bin/tssh
cpu_brand=$(sysctl machdep.cpu |grep brand_string)
down=https://github.91chifun.workers.dev/https://github.com//luanruisong/tssh/releases/download/

然后讨了个巧直接使用github的api来获取最新版release

version=$(wget -qO- -t1 -T2 "https://api.github.com/repos/luanruisong/tssh/releases/latest" | grep "tag_name" | head -n 1 | awk -F ":" '{print $2}' | sed 's/\"//g;s/,//g;s/ //g')

然后根据当前cpu信息选择下载哪个二进制包

suffex=intel
result=$(echo $cpu_brand | grep "Apple M1")
if [[ "$result" != "" ]]
 then
     suffex=appleSilicon
fi
sudo wget -O $tag $down$version/tssh-$suffex
sudo chmod +x $tag

这里是直接把二进制文件放到了 /usr/local/bin/tssh

所以需要sudo 并输入本机密码,后续的chmode +x 也是一样

就这样一键安装就完成了,喜欢的小伙伴可以去下载一个玩玩。

结束