diff --git a/.gitignore b/.gitignore index a79eba8..724d319 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ servers.json -releases/ \ No newline at end of file +releases/ +.idea/ +main +test.go +app.log diff --git a/README.md b/README.md index 4a2b3ed..6b699eb 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,91 @@ # autossh -go写的一个ssh远程客户端。可一键登录远程服务器,主要用来弥补Mac/Linux Terminal ssh无法保存密码的不足。 +一个ssh远程客户端,可一键登录远程服务器,主要用来弥补Mac/Linux Terminal ssh无法保存密码的不足。 -使用Mac开发已有几个月,一直没有找到比较好用的ssh客户端。SecureCRT有Mac版,始终觉得没有自带的Terminal好用。而Terminal只是一个终端, -对于经常要通过ssh远程操作的人来说,功能还是太弱了。 +![演示](https://raw.githubusercontent.com/islenbo/autossh/b3e18c35ebced882ace59be7843d9a58d1ac74d7/doc/images/ezgif-1-a4ddae192f.gif) -其间,我也试过自己写一些shell来辅助,如:`alias sshlocal="ssh root@192.168.33.10"`,但是它无法记住密码自动登录。 -再如,使用sshpass实现记住密码,但用着还是各种别扭。原因: -- 这些功能都是编写shell实现的,本人对shell编程并不擅长 -- shell脚本逼格不够高 +## 版本说明 +这是一个全新的autossh,无法兼容v0.2及以下版本,升级前请做好备份!新版配置文件由原来的`servers.json`改为`config.json`, +升级时可将旧配置文件的列表插入到新配置文件的`servers`节点下 -最后,下定决心用golang写一个ssh client。为什么不用C或者Java?因为golang是世界上最好的编译语言,PHP是世界上最好的脚本语言。 +注:旧版servers中method=pem需要更新为method=key -## 版本 -v0.2 +## 功能说明 +- 支持分组 +- 支持显示/隐藏主机详情(show_detail) +- 支持options(目前仅支持ServerAliveInterval) +- 允许配置文件中server默认值为空 +- 允许指定配置文件目径 +- 修复终端窗口大小改变时无法自适应的bug ## 下载 [https://github.com/islenbo/autossh/releases](https://github.com/islenbo/autossh/releases) -## 配置 -下载编译好的二进制包autossh,在autossh同级目录下新建一个servers.json文件。 -编辑servers.json,内容可参考server.example.json +## 安装 +- 下载编译好的二进制包autossh,放在指目录下,如`~/autossh`或`/usr/loca/autossh` +- 同级目录下新建`config.json`文件,参考`config.example.json` +- 将安装目录加入环境变量中,或指定别名`alias autossh=your autossh path/autossh` + +## config.json ```json -[ - { - "name": "vagrant", // 显示名称 - "ip": "192.168.33.10", // 服务器IP或域名 - "port": 22, // 端口 - "user": "root", // 用户名 - "password": "vagrant", // 密码 - "method": "password" // 认证方式,目前支持password和pem +{ + "show_detail": true, // 显示主机详情 + "options": { // 全局配置 + "ServerAliveInterval": 30 // 发送心跳包时间,同 ssh -o ServerAliveInterval=30 }, - { - "name": "ssh-pem", - "ip": "192.168.33.11", - "port": 22, - "user": "root", - "password": "your pem file password or empty", // pem密钥密码,若无密码则留空 - "method": "pem", // pem密钥认证 - "key": "your pem file path" // pem密钥文件绝对路径 - } - // ...可配置多个 -] -``` -保存servers.json,执行autossh,即可看到相应服务器信息,输入对应序号,即可自动登录到服务器 -![登录演示](https://github.com/islenbo/autossh/raw/master/doc/images/demo.gif) + "servers": [ + { + "name": "vagrant", // 显示名称 + "ip": "192.168.33.10", // 主机地址 + "port": 22, // 端口号,可省略,默认为22 + "user": "root", // 用户名 + "password": "vagrant", // 密码,使用无密码的key登录时可省略 + "method": "password", // 认证方式,可省略,默认值为password,可选项有password、key + "key": "", // 密钥路径,method=key时有效,可省略,默认为~/.ssh/id_rsa + "options": { // 自定义配置,会覆盖配置中相同的值 + "ServerAliveInterval": 20 + } + }, + { + "name": "vagrant-key", + "ip": "192.168.33.10", + "user": "root", + "method": "key" + } + ], + "groups": [ + { + "group_name": "your group name", + "prefix": "a", + "servers": [ + { + "name": "example1", + "ip": "192.168.33.10", + "user": "root", + "password": "root" + }, + { + "name": "example2", + "ip": "192.168.33.10", + "user": "root", + "password": "root" + } + ] + }, + { + "group_name": "group2", + "prefix": "b", + "servers": [ + ] + } + ] +} -## 高级用法 -设置alias,可在任意目录下调用 -```bash -[root@localhost ~]# vim /etc/profile -在行尾追加 alias autossh="~/autossh_path/autossh" -[root@localhost ~]# . /etc/profile -``` -更多快捷操作,可调用 `--help` 查看 -```bash -[root@localhost autossh]# autossh --help -go写的一个ssh远程客户端。可一键登录远程服务器,主要用来弥补Mac/Linux Terminal ssh无法保存密码的不足。 -基本用法: - 直接输入autossh不带任何参数,列出所有服务器,输入对应编号登录。 -参数: - -v, --version 显示 autossh 的版本信息。 - -h, --help 显示帮助信息。 -操作: - list 显示所有server。 - add 添加一个 server。如:autossh add vagrant。 - edit 编辑一个 server。如:autossh edit vagrant。 - remove 删除一个 server。如:autossh remove vagrant ``` ## Q&A - Q: Downloads中为什么没有Windows的包? -- A: Windows下有很多优秀的ssh工具,autossh主要面向Mac/Linux群体。 - -- Q: 为什么要设置alias而不将autossh放到/usr/bin/下? -- A: autossh核心文件有两个,autossh和servers.json且必须处于同级目录下,所以建议放在其他目录,通过alias调用。 +- A: Windows下有很多ssh工具,autossh主要是面向Mac/Linux群体。 ## 编译 go build main.go @@ -82,11 +93,3 @@ go build main.go ## 依赖包 - golang.org/x/crypto/ssh -## TODO -- [x] -v, --version 查看版本号 -- [x] -h, --help 显示帮助 -- [x] list 显示所有server -- [x] add 添加一个server -- [x] remove name 删除一个server -- [x] edit name 编辑一个server - diff --git a/build.sh b/build.sh index 9ec0f43..6c430ce 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,8 @@ #!/bin/bash -VERSION="v0.1" PROJECT="autossh" +VERSION="v1.0.0" +BUILD=`date +%FT%T%z` function build() { os=$1 @@ -11,8 +12,8 @@ function build() { echo "build ${package} ..." mkdir -p "./releases/${package}" - CGO_ENABLED=0 GOOS=${os} GOARCH=${arch} go build -o "./releases/${package}/autossh" main.go - cp ./servers.example.json "./releases/${package}/servers.json" + CGO_ENABLED=0 GOOS=${os} GOARCH=${arch} go build -o "./releases/${package}/autossh" -ldflags "-X main.Version=${VERSION} -X main.Build=${BUILD}" main.go + cp ./config.example.json "./releases/${package}/config.json" cd ./releases/ zip -r "./${package}.zip" "./${package}" echo "clean ${package}" diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..9da3524 --- /dev/null +++ b/config.example.json @@ -0,0 +1,52 @@ +{ + "show_detail": true, // 显示主机详情 + "options": { // 全局配置 + "ServerAliveInterval": 30 // 发送心跳包时间,同 ssh -o ServerAliveInterval=30 + }, + "servers": [ + { + "name": "vagrant", // 显示名称 + "ip": "192.168.33.10", // 主机地址 + "port": 22, // 端口号,可省略,默认为22 + "user": "root", // 用户名 + "password": "vagrant", // 密码,使用无密码的key登录时可省略 + "method": "password", // 认证方式,可省略,默认值为password,可选项有password、key + "key": "", // 密钥路径,method=key时有效,可省略,默认为~/.ssh/id_rsa + "options": { // 自定义配置,会覆盖配置中相同的值 + "ServerAliveInterval": 20 + } + }, + { + "name": "vagrant-key", + "ip": "192.168.33.10", + "user": "root", + "method": "key" + } + ], + "groups": [ + { + "group_name": "your group name", + "prefix": "a", + "servers": [ + { + "name": "example1", + "ip": "192.168.33.10", + "user": "root", + "password": "root" + }, + { + "name": "example2", + "ip": "192.168.33.10", + "user": "root", + "password": "root" + } + ] + }, + { + "group_name": "group2", + "prefix": "b", + "servers": [ + ] + } + ] +} diff --git a/core/app.go b/core/app.go index a8ef86f..ea0eae2 100644 --- a/core/app.go +++ b/core/app.go @@ -1,290 +1,383 @@ package core import ( - "fmt" - "strconv" "io/ioutil" "encoding/json" - "os" - "bytes" "errors" + "strconv" + "fmt" + "strings" + "bytes" + "os" + "io" + "path/filepath" + "time" +) + +type IndexType int + +const ( + IndexTypeServer IndexType = iota + IndexTypeGroup ) -// 版本号 -const VERSION = "0.2" +type Group struct { + GroupName string `json:"group_name"` + Prefix string `json:"prefix"` + Servers []Server `json:"servers"` +} + +type Config struct { + ShowDetail bool `json:"show_detail"` + Servers []Server `json:"servers"` + Groups []Group `json:"groups"` + Options map[string]interface{} `json:"options"` +} + +type ServerIndex struct { + indexType IndexType + groupIndex int + serverIndex int + server *Server +} type App struct { - ServersPath string - servers []Server + ConfigPath string + config Config + serverIndex map[string]ServerIndex } // 执行脚本 -func (app *App) Exec() { - b, err := ioutil.ReadFile(app.ServersPath) - if err != nil { - panic(err) - } +func (app *App) Init() { + app.serverIndex = make(map[string]ServerIndex) - err = json.Unmarshal(b, &app.servers) - if err != nil { - panic(errors.New("解析servers.json失败:" + err.Error())) - } + // 解析配置 + app.loadConfig() + + app.loadServerMap(true) + + app.show() +} + +func (app *App) saveAndReload() { + app.saveConfig() + app.loadConfig() + app.loadServerMap(false) + app.show() +} + +func (app *App) show() { + //for { + Clear() - if len(os.Args) > 1 { - option := os.Args[1] - switch option { - case "list": - app.list() - case "add": - app.add(app.getArg(2, "")) - case "edit": - app.edit(app.getArg(2, "")) - case "remove": - app.remove(app.getArg(2, "")) - - case "--version": - fallthrough - case "-v": - app.version() - - case "--help": - fallthrough - case "-h": - fallthrough - default: - app.help() + // 输出server + app.showServers() + + // 监听输入 + input, isGlobal := app.checkInput() + if isGlobal { + end := app.handleGlobalCmd(input) + if end { + return } } else { - app.start() + server := app.serverIndex[input].server + Printer.Infoln("你选择了", server.Name) + Log.Category("app").Info("select server", server.Name) + server.Connect() } + //} } -// 启动脚本 -func (app *App) start() { - Printer.Infoln("========== 欢迎使用 Auto SSH ==========") - for i, server := range app.servers { - Printer.Logln(" ["+strconv.Itoa(i+1)+"]", server.Name) +func (app *App) handleGlobalCmd(cmd string) bool { + switch strings.ToLower(cmd) { + case "exit": + return true + case "edit": + app.handleEdit() + return false + case "add": + app.handleAdd() + return false + case "remove": + app.handleRemove() + return false + default: + Printer.Errorln("指令无效") + return false } - Printer.Infoln("=======================================") - - server := app.inputSh() - Printer.Infoln("你选择了: " + server.Name) - server.Connection() } // 编辑 -func (app *App) edit(name string) { - exists, index := app.serverExists(name) - if !exists { - Printer.Errorln("Server", name, "不存在") +func (app *App) handleEdit() { + Printer.Info("请输入相应序号(exit退出当前操作):") + id := "" + fmt.Scanln(&id) + + if strings.ToLower(id) == "exit" { + app.show() return } - server := &app.servers[index] - var def string - - def = server.Ip - Printer.Info("请输入IP(default: " + def + "):") - fmt.Scanln(&server.Ip) - if server.Ip == "" { - server.Ip = def + serverIndex, ok := app.serverIndex[id] + if !ok { + Printer.Errorln("序号不存在") + app.handleEdit() + return } - def = strconv.Itoa(server.Port) - Printer.Info("请输入Port(default: " + def + "):") - fmt.Scanln(&server.Port) - if server.Port == 0 { - port, err := strconv.Atoi(def) + serverIndex.server.Edit() + app.saveAndReload() +} - if err != nil { - Printer.Errorln("Port illegality") - return - } else { - server.Port = port - } +// 移除 +func (app *App) handleRemove() { + Printer.Info("请输入相应序号(exit退出当前操作):") + id := "" + fmt.Scanln(&id) + + if strings.ToLower(id) == "exit" { + app.show() + return } - def = server.User - Printer.Info("请输入User(default: " + def + "):") - fmt.Scanln(&server.User) - if server.User == "" { - server.User = def + serverIndex, ok := app.serverIndex[id] + if !ok { + Printer.Errorln("序号不存在") + app.handleEdit() + return } - def = server.Method - Printer.Info("请输入Method[password/pem](default: " + def + "):") - fmt.Scanln(&server.Method) - if server.Method == "" { - server.Method = def + if serverIndex.indexType == IndexTypeServer { + servers := app.config.Servers + app.config.Servers = append(servers[:serverIndex.serverIndex], servers[serverIndex.serverIndex+1:]...) + } else { + servers := app.config.Groups[serverIndex.groupIndex].Servers + servers = append(servers[:serverIndex.serverIndex], servers[serverIndex.serverIndex+1:]...) + app.config.Groups[serverIndex.groupIndex].Servers = servers } - server.Password = "" - if server.Method == "pem" { - def = server.Key - Printer.Info("请输入pem证书绝对目录(default: " + def + "):") - fmt.Scanln(&server.Key) - if server.Key == "" { - server.Key = def - } + app.saveAndReload() +} - Printer.Info("请输入pem证书密码(若无请留空):") - fmt.Scanln(&server.Password) +// 新增 +func (app *App) handleAdd() { + groups := make(map[string]*Group) + for i := range app.config.Groups { + group := &app.config.Groups[i] + groups[group.Prefix] = group + Printer.Info("["+group.Prefix+"]"+group.GroupName, "\t") + } + Printer.Infoln("[其他值]默认组") + Printer.Info("请输入要插入的组:") + g := "" + fmt.Scanln(&g) + + server := Server{} + server.Format() + server.Edit() + + group, ok := groups[g] + if ok { + group.Servers = append(group.Servers, server) } else { - Printer.Info("请输入Password(若无请留空):") - fmt.Scanln(&server.Password) + app.config.Servers = append(app.config.Servers, server) } - err := app.saveServers() + app.saveAndReload() +} + +// 保存配置文件 +func (app *App) saveConfig() error { + b, err := json.Marshal(app.config) if err != nil { - Printer.Errorln("保存到servers.json失败:", err) - } else { - Printer.Infoln("保存成功") + return err } -} -// 删除 -func (app *App) remove(name string) { - exists, index := app.serverExists(name) + var out bytes.Buffer + err = json.Indent(&out, b, "", "\t") + if err != nil { + return err + } - if exists { - app.servers = append(app.servers[:index], app.servers[index+1:]...) - err := app.saveServers() - if err != nil { - Printer.Errorln("保存到servers.json失败:", err) - } else { - Printer.Infoln("删除成功") - } - } else { - Printer.Errorln("Server", name, "不存在") + err = app.backConfig() + if err != nil { + return err } + + return ioutil.WriteFile(app.ConfigPath, out.Bytes(), os.ModePerm) } -// 添加 -func (app *App) add(name string) { - if name == "" { - Printer.Errorln("server name 不能为空") - return +func (app *App) backConfig() error { + srcFile, err := os.Open(app.ConfigPath) + if err != nil { + return err } - if exists, _ := app.serverExists(name); exists { - Printer.Errorln("server", name, "已存在") - return + defer srcFile.Close() + + path, _ := filepath.Abs(filepath.Dir(app.ConfigPath)) + backupFile := path + "/config-" + time.Now().Format("20060102150405") + ".json" + desFile, err := os.Create(backupFile) + if err != nil { + return err } + defer desFile.Close() - server := Server{Name: name} + _, err = io.Copy(desFile, srcFile) + if err != nil { + return err + } - Printer.Info("请输入IP:") - fmt.Scanln(&server.Ip) + Printer.Infoln("配置文件已备份:", backupFile) + return nil +} - Printer.Info("请输入Port:") - fmt.Scanln(&server.Port) +// 检查输入 +func (app *App) checkInput() (string, bool) { + flag := "" + for { + fmt.Scanln(&flag) + Log.Category("app").Info("input scan:", flag) - Printer.Info("请输入User:") - fmt.Scanln(&server.User) + if app.isGlobalInput(flag) { + return flag, true + } - Printer.Info("请输入Method[password/pem]:") - fmt.Scanln(&server.Method) + if _, ok := app.serverIndex[flag]; !ok { + Printer.Errorln("输入有误,请重新输入") + } else { + return flag, false + } + } - if server.Method == "pem" { - Printer.Info("请输入pem证书绝对目录:") - fmt.Scanln(&server.Key) + panic(errors.New("输入有误")) +} - Printer.Info("请输入pem证书密码(若无请留空):") - fmt.Scanln(&server.Password) - } else { - Printer.Info("请输入Password(若无请留空):") - fmt.Scanln(&server.Password) +// 判断是否全局输入 +func (app *App) isGlobalInput(flag string) bool { + switch flag { + case "edit": + fallthrough + case "add": + fallthrough + case "remove": + fallthrough + case "exit": + return true + + default: + return false } +} - app.servers = append(app.servers, server) - err := app.saveServers() +// 加载配置文件 +func (app *App) loadConfig() { + b, _ := ioutil.ReadFile(app.ConfigPath) + err := json.Unmarshal(b, &app.config) if err != nil { - Printer.Errorln("保存到servers.json失败:", err) - } else { - Printer.Infoln("添加成功") + Printer.Errorln("加载配置文件失败", err) + panic(errors.New("加载配置文件失败:" + err.Error())) } } -// 保存servers到servers.json文件 -func (app *App) saveServers() error { - b, err := json.Marshal(app.servers) - if err != nil { - return err +// 打印列表 +func (app *App) showServers() { + maxlen := app.separatorLength() + app.formatSeparator(" 欢迎使用 Auto SSH ", "=", maxlen) + for i, server := range app.config.Servers { + Printer.Logln(app.recordServer(strconv.Itoa(i+1), server)) } - var out bytes.Buffer - err = json.Indent(&out, b, "", "\t") - if err != nil { - return err + for _, group := range app.config.Groups { + if len(group.Servers) == 0 { + continue + } + + app.formatSeparator(" "+group.GroupName+" ", "_", maxlen) + for i, server := range group.Servers { + Printer.Logln(app.recordServer(group.Prefix+strconv.Itoa(i+1), server)) + } } - return ioutil.WriteFile(app.ServersPath, out.Bytes(), os.ModePerm) + app.formatSeparator("", "=", maxlen) + Printer.Logln("", "[add] 添加", " ", "[edit] 编辑", " ", "[remove] 删除") + Printer.Logln("", "[exit]\t退出") + app.formatSeparator("", "=", maxlen) + Printer.Info("请输入序号或操作: ") } -// 打印列表 -func (app *App) list() { - for _, server := range app.servers { - Printer.Logln(server.Name) - } -} +func (app *App) formatSeparator(title string, c string, maxlength float64) { -// 版本信息 -func (app *App) version() { - fmt.Println("Autossh", VERSION, "。") - fmt.Println("由 Lenbo 编写,项目地址:https://github.com/islenbo/autossh。") -} + charslen := int((maxlength - ZhLen(title)) / 2) + chars := "" + for i := 0; i < charslen; i ++ { + chars += c + } -// 显示帮助信息 -func (app *App) help() { - fmt.Println("go写的一个ssh远程客户端。可一键登录远程服务器,主要用来弥补Mac/Linux Terminal ssh无法保存密码的不足。") - fmt.Println("基本用法:") - fmt.Println(" 直接输入autossh不带任何参数,列出所有服务器,输入对应编号登录。") - fmt.Println("参数:") - fmt.Println(" -v, --version", "\t", "显示 autossh 的版本信息。") - fmt.Println(" -h, --help ", "\t", "显示帮助信息。") - fmt.Println("操作:") - fmt.Println(" list ", "\t", "显示所有server。") - fmt.Println(" add ", "\t", "添加一个 server。如:autossh add vagrant。") - fmt.Println(" edit ", "\t", "编辑一个 server。如:autossh edit vagrant。") - fmt.Println(" remove ", "\t", "删除一个 server。如:autossh remove vagrant。") + Printer.Infoln(chars + title + chars) } -// 判断server是否存在 -func (app *App) serverExists(name string) (bool, int) { - for index, server := range app.servers { - if server.Name == name { - return true, index +func (app *App) separatorLength() float64 { + maxlength := 50.0 + for _, group := range app.config.Groups { + length := ZhLen(group.GroupName) + if length > maxlength { + maxlength = length + 10 } } - return false, -1 + return maxlength } -// 接收输入,获取对应sh脚本 -func (app *App) inputSh() Server { - Printer.Info("请输入序号: ") - input := "" - fmt.Scanln(&input) - num, err := strconv.Atoi(input) - if err != nil { - Printer.Errorln("输入有误,请重新输入") - return app.inputSh() - } +// 加载 +func (app *App) loadServerMap(check bool) { + Log.Category("app").Info("server count", len(app.config.Servers), "group count", len(app.config.Groups)) + + for i := range app.config.Servers { + server := &app.config.Servers[i] + server.Format() + flag := strconv.Itoa(i + 1) + + if _, ok := app.serverIndex[flag]; ok && check { + panic(errors.New("标识[" + flag + "]已存在,请检查您的配置文件")) + } - if num <= 0 || num > len(app.servers) { - Printer.Errorln("输入有误,请重新输入") - return app.inputSh() + server.MergeOptions(app.config.Options, false) + app.serverIndex[flag] = ServerIndex{ + indexType: IndexTypeServer, + groupIndex: -1, + serverIndex: i, + server: server, + } } - return app.servers[num-1] + for i := range app.config.Groups { + group := &app.config.Groups[i] + for j := range group.Servers { + server := &group.Servers[j] + server.Format() + flag := group.Prefix + strconv.Itoa(j+1) + + if _, ok := app.serverIndex[flag]; ok && check { + panic(errors.New("标识[" + flag + "]已存在,请检查您的配置文件")) + } + + server.MergeOptions(app.config.Options, false) + app.serverIndex[flag] = ServerIndex{ + indexType: IndexTypeGroup, + groupIndex: i, + serverIndex: j, + server: server, + } + } + } } -// 获取参数 -func (app *App) getArg(index int, def string) string { - max := len(os.Args) - 1 - if max >= index { - return os.Args[index] +func (app *App) recordServer(flag string, server Server) string { + if app.config.ShowDetail { + return " [" + flag + "]" + "\t" + server.Name + " [" + server.User + "@" + server.Ip + "]" + } else { + return " [" + flag + "]" + "\t" + server.Name } - - return def } diff --git a/core/log.go b/core/log.go new file mode 100644 index 0000000..1706b47 --- /dev/null +++ b/core/log.go @@ -0,0 +1,65 @@ +package core + +import ( + "os" + "log" +) + +type logger struct { + File string + category string + level string +} + +var Log logger + +func init() { + logFile, _ := ParsePath("./app.log") + Log = logger{ + File: logFile, + } +} + +func (logger *logger) write(msg ...interface{}) { + if _, err := os.Stat(logger.File); err != nil { + if os.IsNotExist(err) { + _, err := os.Create(logger.File) + if err != nil { + panic(err) + } + } else { + panic(err) + } + } + + logFile, err := os.OpenFile(logger.File, os.O_RDWR|os.O_APPEND, 0666) + defer logFile.Close() + if err != nil { + panic(err) + } + + // 创建一个日志对象 + l := log.New(logFile, logger.level, log.LstdFlags) + + s := make([]interface{}, 1) + s[0] = "[" + logger.category + "]" + msg = append(s, msg) + + l.Println(msg...) + logger.category = "" +} + +func (logger *logger) Category(category string) *logger { + logger.category = category + return logger +} + +func (logger *logger) Info(msg ...interface{}) { + logger.level = "[Info]" + logger.write(msg...) +} + +func (logger *logger) Error(msg ...interface{}) { + logger.level = "[Error]" + logger.write(msg...) +} diff --git a/core/params.go b/core/params.go new file mode 100644 index 0000000..5017861 --- /dev/null +++ b/core/params.go @@ -0,0 +1,35 @@ +package core + +import "os" + +var paramsMap map[string]string + +var Params params + +type params struct { +} + +func init() { + paramsMap = make(map[string]string) +} + +func (p params) Get(key string) *string { + if v, ok := paramsMap[key]; ok { + return &v + } + + for _, param := range os.Args { + if param[:len(key)] == key { + val := param[len(key)+1:] + paramsMap[key] = val + break + } + } + + v, ok := paramsMap[key] + if !ok { + return nil + } else { + return &v + } +} diff --git a/core/print.go b/core/print.go index fb6f69c..481ad00 100644 --- a/core/print.go +++ b/core/print.go @@ -8,17 +8,11 @@ type Print struct { var Printer Print // 打印一行信息 -// 字体颜色为绿色 +// 字体颜色为默色 func (print Print) Logln(a ...interface{}) { fmt.Println(a...) } -// 打印信息(不换行) -// 字体颜色为绿色 -func (print Print) Log(a ...interface{}) { - fmt.Print(a...) -} - // 打印一行信息 // 字体颜色为绿色 func (print Print) Infoln(a ...interface{}) { @@ -43,10 +37,3 @@ func (print Print) Errorln(a ...interface{}) { fmt.Print("\033[0m") } -// 打印信息(不换行) -// 字体颜色为红色 -func (print Print) Error(a ...interface{}) { - fmt.Print("\033[31m") - fmt.Print(a...) - fmt.Print("\033[0m") -} diff --git a/core/server.go b/core/server.go index 49b36e3..6e11098 100644 --- a/core/server.go +++ b/core/server.go @@ -6,26 +6,43 @@ import ( "strconv" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/terminal" - "errors" "io/ioutil" + "time" + "fmt" + "strings" ) type Server struct { - Name string `json:"name"` - Ip string `json:"ip"` - Port int `json:"port"` - User string `json:"user"` - Password string `json:"password"` - Method string `json:"method"` - Key string `json:"key"` + Name string `json:"name"` + Ip string `json:"ip"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + Method string `json:"method"` + Key string `json:"key"` + Options map[string]interface{} `json:"options"` + + termWidth int + termHeight int +} + +func (server *Server) Format() { + if server.Port == 0 { + server.Port = 22 + } + + if server.Method == "" { + server.Method = "password" + } } // 执行远程连接 -func (server *Server) Connection() { +func (server *Server) Connect() { auths, err := parseAuthMethods(server) if err != nil { Printer.Errorln("鉴权出错:", err) + Log.Category("server").Error("auth fail", err) return } @@ -37,17 +54,29 @@ func (server *Server) Connection() { }, } + // 默认端口为22 + if server.Port == 0 { + server.Port = 22 + } + addr := server.Ip + ":" + strconv.Itoa(server.Port) client, err := ssh.Dial("tcp", addr, config) if err != nil { - Printer.Errorln("建立连接出错:", err) + if ErrorAssert(err, "ssh: unable to authenticate") { + Printer.Errorln("连接失败,请检查密码/密钥是否有误") + return + } + + Printer.Errorln("ssh dial fail:", err) + Log.Category("server").Error("ssh dial fail", err) return } defer client.Close() session, err := client.NewSession() if err != nil { - Printer.Errorln("创建Session出错:", err) + Printer.Errorln("create session fail:", err) + Log.Category("server").Error("create session fail", err) return } @@ -57,19 +86,17 @@ func (server *Server) Connection() { oldState, err := terminal.MakeRaw(fd) if err != nil { Printer.Errorln("创建文件描述符出错:", err) + Log.Category("server").Error("创建文件描述符出错", err) return } + stopKeepAliveLoop := server.startKeepAliveLoop(session) + defer close(stopKeepAliveLoop) + session.Stdout = os.Stdout session.Stderr = os.Stderr session.Stdin = os.Stdin - termWidth, termHeight, err := terminal.GetSize(fd) - if err != nil { - Printer.Errorln("获取窗口宽高出错:", err) - return - } - defer terminal.Restore(fd, oldState) modes := ssh.TerminalModes{ @@ -78,34 +105,108 @@ func (server *Server) Connection() { ssh.TTY_OP_OSPEED: 14400, } - if err := session.RequestPty("xterm-256color", termHeight, termWidth, modes); err != nil { + server.termWidth, server.termHeight, _ = terminal.GetSize(fd) + if err := session.RequestPty("xterm-256color", server.termHeight, server.termWidth, modes); err != nil { Printer.Errorln("创建终端出错:", err) + Log.Category("server").Error("创建终端出错", err) return } + winChange := server.listenWindowChange(session, fd) + defer close(winChange) + err = session.Shell() if err != nil { Printer.Errorln("执行Shell出错:", err) + Log.Category("server").Error("执行Shell出错", err) return } err = session.Wait() if err != nil { //Printer.Errorln("执行Wait出错:", err) + Log.Category("server").Error("执行Wait出错", err) return } } +// 监听终端窗口变化 +func (server *Server) listenWindowChange(session *ssh.Session, fd int) chan struct{} { + terminate := make(chan struct{}) + go func() { + for { + select { + case <-terminate: + return + default: + termWidth, termHeight, _ := terminal.GetSize(fd) + + if server.termWidth != termWidth || server.termHeight != termHeight { + server.termHeight = termHeight + server.termWidth = termWidth + session.WindowChange(termHeight, termWidth) + } + + time.Sleep(time.Millisecond * 3) + } + } + }() + + return terminate +} + +// 发送心跳包 +func (server *Server) startKeepAliveLoop(session *ssh.Session) chan struct{} { + terminate := make(chan struct{}) + go func() { + for { + select { + case <-terminate: + return + default: + if val, ok := server.Options["ServerAliveInterval"]; ok && val != nil { + _, err := session.SendRequest("keepalive@bbr", true, nil) + if err != nil { + Log.Category("server").Error("keepAliveLoop fail", err) + } + + t := time.Duration(server.Options["ServerAliveInterval"].(float64)) + time.Sleep(time.Second * t) + } + } + } + }() + return terminate +} + +// 合并选项 +func (server *Server) MergeOptions(options map[string]interface{}, overwrite bool) { + if server.Options == nil { + server.Options = make(map[string]interface{}) + } + + for k, v := range options { + if overwrite { + server.Options[k] = v + } else { + if _, ok := server.Options[k]; !ok { + server.Options[k] = v + } + } + + } +} + // 解析鉴权方式 func parseAuthMethods(server *Server) ([]ssh.AuthMethod, error) { sshs := []ssh.AuthMethod{} - switch server.Method { + switch strings.ToLower(server.Method) { case "password": sshs = append(sshs, ssh.Password(server.Password)) break - case "pem": + case "key": method, err := pemKey(server) if err != nil { return nil, err @@ -113,15 +214,21 @@ func parseAuthMethods(server *Server) ([]ssh.AuthMethod, error) { sshs = append(sshs, method) break + // 默认以password方式 default: - return nil, errors.New("无效的密码方式: " + server.Method) + sshs = append(sshs, ssh.Password(server.Password)) } return sshs, nil } -// 解析pem密钥 +// 解析密钥 func pemKey(server *Server) (ssh.AuthMethod, error) { + if server.Key == "" { + server.Key = "~/.ssh/id_rsa" + } + server.Key, _ = ParsePath(server.Key) + pemBytes, err := ioutil.ReadFile(server.Key) if err != nil { return nil, err @@ -140,3 +247,56 @@ func pemKey(server *Server) (ssh.AuthMethod, error) { return ssh.PublicKeys(signer), nil } + +func (server *Server) Edit() { + input := "" + Printer.Info("Name(default=" + server.Name + "):") + fmt.Scanln(&input) + if input != "" { + server.Name = input + input = "" + } + + Printer.Info("Ip(default=" + server.Ip + "):") + fmt.Scanln(&input) + if input != "" { + server.Ip = input + input = "" + } + + Printer.Info("Port(default=" + strconv.Itoa(server.Port) + "):") + fmt.Scanln(&input) + if input != "" { + port, _ := strconv.Atoi(input) + server.Port = port + input = "" + } + + Printer.Info("User(default=" + server.User + "):") + fmt.Scanln(&input) + if input != "" { + server.User = input + input = "" + } + + Printer.Info("Password(default=" + server.Password + "):") + fmt.Scanln(&input) + if input != "" { + server.Password = input + input = "" + } + + Printer.Info("Method(default=" + server.Method + "):") + fmt.Scanln(&input) + if input != "" { + server.Method = input + input = "" + } + + Printer.Info("Key(default=" + server.Key + "):") + fmt.Scanln(&input) + if input != "" { + server.Key = input + input = "" + } +} diff --git a/core/util.go b/core/util.go new file mode 100644 index 0000000..4148441 --- /dev/null +++ b/core/util.go @@ -0,0 +1,117 @@ +package core + +import ( + "runtime" + "os" + "bytes" + "os/exec" + "strings" + "os/user" + "errors" + "path/filepath" + "unicode" +) + +// 错误断言 +func ErrorAssert(err error, assert string) bool { + return strings.Contains(err.Error(), assert) +} + +// 清屏 +func Clear() { + var cmd exec.Cmd + if "windows" == runtime.GOOS { + cmd = *exec.Command("cmd", "/c", "cls") + } else { + cmd = *exec.Command("clear") + } + + cmd.Stdout = os.Stdout + cmd.Run() +} + +// 计算字符宽度(中文) +func ZhLen(str string) float64 { + length := 0.0 + for _, c := range str { + if unicode.Is(unicode.Scripts["Han"], c) { + length += 2 + } else { + length += 1 + } + } + + return length +} + +// 解析路径 +func ParsePath(path string) (string, error) { + str := []rune(path) + firstKey := string(str[:1]) + + if firstKey == "~" { + home, err := home() + if err != nil { + return "", err + } + + return home + string(str[1:]), nil + } else if firstKey == "." { + p, _ := filepath.Abs(filepath.Dir(os.Args[0])) + return p + "/" + path, nil + } else { + return path, nil + } +} + +func home() (string, error) { + u, err := user.Current() + if nil == err { + return u.HomeDir, nil + } + + // cross compile support + + if "windows" == runtime.GOOS { + return homeWindows() + } + + // Unix-like system, so just assume Unix + return homeUnix() +} + +func homeUnix() (string, error) { + // First prefer the HOME environmental variable + if home := os.Getenv("HOME"); home != "" { + return home, nil + } + + // If that fails, try the shell + var stdout bytes.Buffer + cmd := exec.Command("sh", "-c", "eval echo ~$USER") + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + + result := strings.TrimSpace(stdout.String()) + if result == "" { + return "", errors.New("blank output when reading home directory") + } + + return result, nil +} + +func homeWindows() (string, error) { + drive := os.Getenv("HOMEDRIVE") + path := os.Getenv("HOMEPATH") + home := drive + path + if drive == "" || path == "" { + home = os.Getenv("USERPROFILE") + } + if home == "" { + return "", errors.New("HOMEDRIVE, HOMEPATH, and USERPROFILE are blank") + } + + return home, nil +} diff --git a/doc/images/ezgif-1-a4ddae192f.gif b/doc/images/ezgif-1-a4ddae192f.gif new file mode 100644 index 0000000..f7374ec Binary files /dev/null and b/doc/images/ezgif-1-a4ddae192f.gif differ diff --git a/main.go b/main.go old mode 100644 new mode 100755 index 50f4381..06bccce --- a/main.go +++ b/main.go @@ -2,18 +2,87 @@ package main import ( "autossh/core" - "path/filepath" "os" + "path/filepath" "fmt" + "strings" +) + +var ( + Version = "unknown" + Build = "unknown" ) func main() { - path, err := filepath.Abs(filepath.Dir(os.Args[0])) + configPath := "" + if len(os.Args) > 1 { + option := strings.Split(os.Args[1], "=") + + switch option[0] { + case "--config": + configPath = *core.Params.Get("--config") + case "-c": + configPath = *core.Params.Get("-c") + + case "--help": + fallthrough + case "-h": + help() + return + + case "--version": + fallthrough + case "-v": + version() + return + } + } + + defer func() { + if err := recover(); err != nil { + core.Log.Category("main").Error("recover", err) + } + }() + + if configPath == "" { + configPath, _ = filepath.Abs(filepath.Dir(os.Args[0])) + configPath = configPath + "/config.json" + } else { + configPath, _ = core.ParsePath(configPath) + } + + core.Log.Category("main").Info("config path=", configPath) + + _, err := os.Stat(configPath) if err != nil { - fmt.Println("error:", err) + if os.IsNotExist(err) { + core.Printer.Errorln("config file", configPath+" not exists") + core.Log.Category("main").Error("config file not exists") + } else { + core.Printer.Errorln("unknown error", err) + core.Log.Category("main").Error("unknown error", err) + } + return } - app := core.App{ServersPath: path + "/servers.json"} - app.Exec() + app := core.App{ + ConfigPath: configPath, + } + app.Init() +} + +// 版本信息 +func version() { + fmt.Println("autossh " + Version + " Build " + Build + "。") + fmt.Println("由 Lenbo 编写,项目地址:https://github.com/islenbo/autossh。") +} + +// 显示帮助信息 +func help() { + fmt.Println("一个ssh远程客户端,可一键登录远程服务器,主要用来弥补Mac/Linux Terminal ssh无法保存密码的不足。") + fmt.Println("参数:") + fmt.Println(" -c, --config ", "default=./config.json \t", "指定配置文件。") + fmt.Println(" -h, --help ", " \t", "显示帮助信息。") + fmt.Println(" -v, --version", " \t", "显示 autossh 的版本信息。") } diff --git a/servers.example.json b/servers.example.json deleted file mode 100644 index 9cf77bc..0000000 --- a/servers.example.json +++ /dev/null @@ -1,19 +0,0 @@ -[ - { - "name": "vagrant", - "ip": "192.168.33.10", - "port": 22, - "user": "root", - "password": "vagrant", - "method": "password" - }, - { - "name": "ssh-pem", - "ip": "192.168.33.11", - "port": 22, - "user": "root", - "password": "your pem file password or empty", - "method": "pem", - "key": "your pem file path" - } -] \ No newline at end of file