├── static ├── add.gif ├── del.gif ├── help.gif ├── list.gif └── addmore.gif ├── go.mod ├── store ├── cache.go ├── store.go ├── internal.go ├── config_batch.go └── config.go ├── cmd ├── help.go ├── cmd.go └── internal.go ├── cm.sh ├── constant ├── constant_windows.go ├── constant_unix.go └── constant.go ├── ssh ├── resize_unix.go ├── resize_windows.go └── ssh.go ├── install.sh ├── README.md ├── main.go ├── go.sum └── LICENSE /static/add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luanruisong/tssh/HEAD/static/add.gif -------------------------------------------------------------------------------- /static/del.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luanruisong/tssh/HEAD/static/del.gif -------------------------------------------------------------------------------- /static/help.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luanruisong/tssh/HEAD/static/help.gif -------------------------------------------------------------------------------- /static/list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luanruisong/tssh/HEAD/static/list.gif -------------------------------------------------------------------------------- /static/addmore.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luanruisong/tssh/HEAD/static/addmore.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/luanruisong/tssh 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/containerd/console v1.0.4 7 | github.com/manifoldco/promptui v0.9.0 8 | golang.org/x/crypto v0.45.0 9 | ) 10 | 11 | require ( 12 | github.com/chzyer/readline v1.5.1 // indirect 13 | golang.org/x/sys v0.38.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /store/cache.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | func GetBatchConfig() *configBatch { 4 | cacheOnce.Do(func() { 5 | global = &configBatch{} 6 | global.Load() 7 | }) 8 | return global 9 | } 10 | 11 | func ListConfig() []*SSHConfig { 12 | return GetBatchConfig().List() 13 | } 14 | 15 | func GetConfig(name string) *SSHConfig { 16 | return GetBatchConfig().Get(name) 17 | } 18 | -------------------------------------------------------------------------------- /cmd/help.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/luanruisong/tssh/constant" 8 | ) 9 | 10 | func Logo(version string) { 11 | if len(version) > 0 { 12 | s := strings.ReplaceAll(constant.LogoStr, "unknown", version) 13 | fmt.Print(s) 14 | return 15 | } 16 | fmt.Print(constant.LogoStr) 17 | } 18 | 19 | func Help() { 20 | fmt.Print(constant.HelpStr) 21 | } 22 | -------------------------------------------------------------------------------- /cm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | workdir=$(cd $(dirname $0); pwd) 5 | target="$workdir/other/date/support_`date +%Y%m%d`.go" 6 | if [ ! -f "$target" ]; then 7 | printf "package other\n\n" >> $target 8 | fi 9 | d=`date '+%Y%m%d%H%M%S'` 10 | printf "func FixData_$d()(string,error){ return BuildData_$d()}\n\n" >> "$target" 11 | printf "func BuildData_$d()(string,error){ return FixData_$d()}\n\n" >> "$target" 12 | cd $workdir 13 | git add $target 14 | git commit -am "add func at `date '+%Y-%m-%d %H:%M:%S'`" 15 | git push 16 | -------------------------------------------------------------------------------- /constant/constant_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package constant 4 | 5 | import "github.com/manifoldco/promptui" 6 | 7 | const ( 8 | HOME = "HOMEPATH" 9 | ) 10 | 11 | var ( 12 | ListTpl = &promptui.SelectTemplates{ 13 | Label: "{{ . }}", 14 | Active: "->{{ .FmtName | cyan }} ({{ .User | yellow }}@{{ .Ip | red }})", 15 | Inactive: " {{ .FmtName | cyan }} ({{ .User | yellow }}@{{ .Ip | red }})", 16 | Selected: "start connect {{ .Name | cyan }}({{ .User | yellow }}@{{ .Ip | red }})...", 17 | Details: Detail, 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /constant/constant_unix.go: -------------------------------------------------------------------------------- 1 | // +build darwin freebsd linux netbsd openbsd solaris 2 | 3 | package constant 4 | 5 | import "github.com/manifoldco/promptui" 6 | 7 | const ( 8 | HOME = "HOME" 9 | ) 10 | 11 | var ( 12 | ListTpl = &promptui.SelectTemplates{ 13 | Label: "{{ . }}", 14 | Active: "\U0001F336 {{ .FmtName | cyan }} ({{ .User | yellow }}@{{ .Ip | red }})", 15 | Inactive: " {{ .FmtName | cyan }} ({{ .User | yellow }}@{{ .Ip | red }})", 16 | Selected: "start connect {{ .Name | cyan }}({{ .User | yellow }}@{{ .Ip | red }})...", 17 | Details: Detail, 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "github.com/luanruisong/tssh/constant" 9 | ) 10 | 11 | func Env() string { 12 | 13 | return cfgPath 14 | } 15 | 16 | func ConfigExists(name string) bool { 17 | return fileExists(path.Join(Env(), name)) 18 | } 19 | 20 | func Del(name string) error { 21 | finalPath := path.Join(Env(), name) 22 | if !fileExists(finalPath) { 23 | return fmt.Errorf("config %s not exists", name) 24 | } 25 | err := os.Remove(finalPath) 26 | if err == nil { 27 | fmt.Println("delete", name, "success") 28 | } 29 | return err 30 | } 31 | 32 | func FmtEnv() { 33 | fmt.Println(constant.EnvName, "=", Env()) 34 | } 35 | -------------------------------------------------------------------------------- /ssh/resize_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || freebsd || linux || netbsd || openbsd || solaris 2 | 3 | package ssh 4 | 5 | import ( 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/containerd/console" 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | // listenWindowResize 监听终端窗口大小变化并通知远程会话 15 | func listenWindowResize(session *ssh.Session, current console.Console) func() { 16 | sigwinchCh := make(chan os.Signal, 1) 17 | signal.Notify(sigwinchCh, syscall.SIGWINCH) 18 | 19 | go func() { 20 | for range sigwinchCh { 21 | if newSize, err := current.Size(); err == nil { 22 | _ = session.WindowChange(int(newSize.Height), int(newSize.Width)) 23 | } 24 | } 25 | }() 26 | 27 | return func() { 28 | signal.Stop(sigwinchCh) 29 | close(sigwinchCh) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | tag=/usr/local/bin/tssh 4 | cpu_brand=$(sysctl machdep.cpu |grep brand_string) 5 | #down=https://github.com/luanruisong/tssh/releases/download/ 6 | down=https://github.91chifun.workers.dev/https://github.com//luanruisong/tssh/releases/download/ 7 | 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') 8 | echo $version 9 | if [ -z "$version" ]; then 10 | echo "can not get latest release" 11 | else 12 | suffex=intel 13 | result=$(echo $cpu_brand | grep "Apple M1") 14 | if [ "$result" != "" ]; then 15 | suffex=appleSilicon 16 | fi 17 | sudo wget -O $tag $down$version/tssh-$suffex 18 | sudo chmod +x $tag 19 | fi 20 | -------------------------------------------------------------------------------- /store/internal.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "sync" 7 | 8 | "github.com/luanruisong/tssh/constant" 9 | ) 10 | 11 | var ( 12 | cfgPath string 13 | global *configBatch 14 | cacheOnce *sync.Once 15 | ) 16 | 17 | func init() { 18 | cacheOnce = &sync.Once{} 19 | cfgPath = os.Getenv(constant.EnvName) 20 | if len(cfgPath) == 0 { 21 | cfgPath = buildConfigPath() 22 | } 23 | if !fileExists(cfgPath) { 24 | _ = os.MkdirAll(cfgPath, os.ModePerm) 25 | } 26 | } 27 | 28 | func buildConfigPath() string { 29 | return path.Join(os.Getenv(constant.HOME), ".tssh", "config") 30 | } 31 | 32 | func fileExists(p string) bool { 33 | _, err := os.Stat(p) //os.Stat获取文件信息 34 | if err != nil { 35 | if os.IsExist(err) { 36 | return true 37 | } 38 | return false 39 | } 40 | return true 41 | } 42 | -------------------------------------------------------------------------------- /store/config_batch.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "path" 7 | ) 8 | 9 | type ( 10 | configBatch struct { 11 | list []*SSHConfig 12 | m map[string]*SSHConfig 13 | } 14 | ) 15 | 16 | func (bc *configBatch) Load() { 17 | if len(bc.list) > 0 && len(bc.m) > 0 { 18 | return 19 | } 20 | env := Env() 21 | dir, err := ioutil.ReadDir(env) 22 | if err != nil { 23 | return 24 | } 25 | list := make([]*SSHConfig, 0) 26 | m := make(map[string]*SSHConfig) 27 | for _, v := range dir { 28 | cfg := &SSHConfig{} 29 | var b []byte 30 | if b, err = ioutil.ReadFile(path.Join(env, v.Name())); err != nil { 31 | return 32 | } 33 | if err = json.Unmarshal(b, cfg); err == nil { 34 | cfg.Name = v.Name() 35 | list = append(list, cfg) 36 | m[cfg.Name] = cfg 37 | } 38 | } 39 | bc.list = list 40 | bc.m = m 41 | } 42 | 43 | func (bc *configBatch) Get(str string) *SSHConfig { 44 | return bc.m[str] 45 | } 46 | 47 | func (bc *configBatch) List() []*SSHConfig { 48 | return bc.list 49 | } 50 | -------------------------------------------------------------------------------- /ssh/resize_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package ssh 4 | 5 | import ( 6 | "time" 7 | 8 | "github.com/containerd/console" 9 | "golang.org/x/crypto/ssh" 10 | ) 11 | 12 | // listenWindowResize Windows 平台通过轮询方式检测窗口大小变化 13 | func listenWindowResize(session *ssh.Session, current console.Console) func() { 14 | done := make(chan struct{}) 15 | 16 | go func() { 17 | var lastWidth, lastHeight uint16 18 | 19 | // 获取初始窗口大小 20 | if size, err := current.Size(); err == nil { 21 | lastWidth = size.Width 22 | lastHeight = size.Height 23 | } 24 | 25 | ticker := time.NewTicker(500 * time.Millisecond) 26 | defer ticker.Stop() 27 | 28 | for { 29 | select { 30 | case <-done: 31 | return 32 | case <-ticker.C: 33 | if size, err := current.Size(); err == nil { 34 | if size.Width != lastWidth || size.Height != lastHeight { 35 | lastWidth = size.Width 36 | lastHeight = size.Height 37 | _ = session.WindowChange(int(size.Height), int(size.Width)) 38 | } 39 | } 40 | } 41 | } 42 | }() 43 | 44 | return func() { 45 | close(done) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tssh 2 | 3 | ## golang 实现的ssh 工具 4 | 5 | ### 安装 6 | 7 | #### 下载安装 8 | 9 | 下载地址 [release](https://github.com/luanruisong/tssh/releases/) 10 | 11 | #### homebrew 安装 12 | 13 | ```shell 14 | $ brew install tssh 15 | ``` 16 | 17 | ## 环境变量 18 | 19 | ### 手动设置 20 | ```shell 21 | export TSSH_HOME=/Users/user/work/ssh_config/ 22 | ``` 23 | ### 默认设置 24 | ```shell 25 | # 默认设置在windows环境下使用%HOMEPATH% 26 | export TSSH_HOME=$HOME/.tssh/config 27 | ``` 28 | 29 | ## 查看帮助 30 | 31 | ![help](./static/help.gif) 32 | 33 | ## 相关操作 34 | 35 | ### 添加一个链接配置 36 | 37 | #### 采用密码模式 38 | 39 | ![add](./static/add.gif) 40 | 41 | #### 指定更多参数 42 | 43 | ![addmore](./static/addmore.gif) 44 | 45 | ### 查看现有链接(2.0) 46 | 47 | ![list](./static/list.gif) 48 | 49 | ### 删除配置 50 | 51 | ![del](./static/list.gif) 52 | 53 | ## 答谢 54 | 55 | ### 跨平台终端解决方案 56 | 57 | 主要解决win下获取终端信息 58 | 59 | 大佬项目链接 [containerd/console](https://github.com/containerd/console) 60 | 61 | ### 更加友好的交互 62 | 63 | 2.0 引入了一个有意思的新包 让我们有更加友好的交互方式 64 | 65 | 大佬项目链接 [manifoldco/promptui](https://github.com/manifoldco/promptui) 66 | 67 | ## 其他 68 | 69 | 解决问题的心路历程 -> [anwu's blog](https://luanruisong.com/post/golang/tssh/) -------------------------------------------------------------------------------- /constant/constant.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | EnvName = "TSSH_HOME" 5 | LogoStr = ` 6 | ______ ______ ______ __ __ 7 | /\__ _\ /\ ___\ /\ ___\ /\ \_\ \ 8 | \/_/\ \/ \ \___ \ \ \___ \ \ \ __ \ 9 | \ \_\ \/\_____\ \/\_____\ \ \_\ \_\ 10 | \/_/ \/_____/ \/_____/ \/_/\/_/ 11 | version 12 | ` 13 | HelpStr = ` 14 | Usage of TSSH: 15 | 16 | env get env info (e|-e) 17 | version get version info (v|-v) 18 | list get config list (l|-l) 19 | conn connect to alias (c|-c) 20 | delete del config by alias (d|-d) 21 | add add config {user@host} (a|-a) 22 | save reset config {user@host} (s|-s) 23 | -P int 24 | set port in (add|save) (default 22) 25 | -k string 26 | set private_key path in (add|save) 27 | -n string 28 | set alias name in (add|save) 29 | -p string 30 | set password in (add|save) 31 | 32 | ` 33 | Detail = ` 34 | ---------------------------------------------------- 35 | {{ "Name:" | faint }} {{ .Name }} 36 | {{ "Ip:" | faint }} {{ .Ip }} 37 | {{ "User:" | faint }} {{ .User }} 38 | {{ "Port:" | faint }} {{ .Port }} 39 | {{ "ConnMode:" | faint }} {{ .ConnMode }} 40 | {{ "SaveAt:" | faint }} {{ .SaveAt }}` 41 | ) 42 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | Copyright © 2021 Luan Ruisong 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | import ( 16 | "fmt" 17 | "os" 18 | 19 | "github.com/luanruisong/tssh/cmd" 20 | "github.com/luanruisong/tssh/store" 21 | ) 22 | 23 | var version string 24 | 25 | func main() { 26 | 27 | //flag.Parse() 28 | cmd.Logo(version) 29 | if len(os.Args) < 2 { 30 | cmd.Help() 31 | return 32 | } 33 | 34 | var ( 35 | flag = os.Args[1] 36 | alias string 37 | args []string 38 | ) 39 | if len(os.Args) >= 3 { 40 | alias = os.Args[2] 41 | args = os.Args[3:] 42 | } 43 | switch flag { 44 | case "h", "-h", "help", "-help": 45 | cmd.Help() 46 | case "v", "-v", "version", "-version": 47 | fmt.Println("version", version) 48 | case "e", "-e", "env", "-env": 49 | store.FmtEnv() 50 | case "d", "-d", "del", "-del": 51 | cmd.Del(alias) 52 | case "a", "-a", "add", "-add": 53 | cmd.Add(alias, args) 54 | case "s", "-s", "save", "-save": 55 | cmd.Save(alias, args) 56 | case "l", "-l", "list", "-list": 57 | cmd.List() 58 | case "c", "-c", "conn", "-conn": 59 | cmd.Conn(alias) 60 | default: 61 | cmd.Help() 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 2 | github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= 3 | github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 4 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 5 | github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= 6 | github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 7 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 8 | github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= 9 | github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 10 | github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= 11 | github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 12 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= 13 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= 14 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 15 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 16 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 17 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 18 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 19 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 20 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 21 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 22 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 23 | -------------------------------------------------------------------------------- /store/config.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path" 8 | "time" 9 | 10 | "github.com/luanruisong/tssh/ssh" 11 | 12 | ssh1 "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | type ( 16 | SSHConfig struct { 17 | Name string `json:"-"` 18 | Ip string 19 | User string 20 | Pwd string 21 | SshKey []byte 22 | Port int 23 | SaveAt string 24 | } 25 | ) 26 | 27 | func NewConfig(name, ip, user, pwd string, sshKey []byte, port int) *SSHConfig { 28 | return &SSHConfig{ 29 | Name: name, 30 | Ip: ip, 31 | User: user, 32 | Pwd: pwd, 33 | SshKey: sshKey, 34 | Port: port, 35 | SaveAt: time.Now().Format("2006-01-02 15:04:05"), 36 | } 37 | } 38 | 39 | func (s *SSHConfig) saveToPath(path string) error { 40 | b, e := json.MarshalIndent(s, "", " ") 41 | if e != nil { 42 | return e 43 | } 44 | err := os.WriteFile(path, b, os.ModePerm) 45 | if err == nil { 46 | fmt.Println("save", s.Name, "success") 47 | } 48 | return err 49 | } 50 | 51 | func (s *SSHConfig) Save() error { 52 | finalPath := path.Join(Env(), s.Name) 53 | if fileExists(finalPath) { 54 | _ = os.Remove(finalPath) 55 | } 56 | return s.saveToPath(finalPath) 57 | } 58 | 59 | func (s *SSHConfig) String() string { 60 | return s.Name 61 | } 62 | func (s *SSHConfig) Conn() (err error) { 63 | var ( 64 | cfg *ssh1.ClientConfig 65 | cli *ssh1.Client 66 | ) 67 | if len(s.SshKey) > 0 { 68 | cfg, err = ssh.PkCfg(s.User, s.SshKey) 69 | } else { 70 | cfg = ssh.PwdCfg(s.User, s.Pwd) 71 | } 72 | if err != nil { 73 | return err 74 | } 75 | cli, err = ssh.Connect(s.Ip, s.Port, cfg) 76 | if err != nil { 77 | return err 78 | } 79 | return ssh.RunTerminal(cli, os.Stdin, os.Stdout, os.Stderr) 80 | } 81 | 82 | func (s *SSHConfig) ConnMode() string { 83 | if len(s.SshKey) > 0 { 84 | return "private_key" 85 | } 86 | return "password" 87 | } 88 | 89 | func (s *SSHConfig) FmtName() string { 90 | name := s.Name 91 | if len(name) > 20 { 92 | name = name[:17] + "..." 93 | } 94 | return fmt.Sprintf("%-20s", name) 95 | } 96 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/manifoldco/promptui" 9 | 10 | "github.com/luanruisong/tssh/constant" 11 | "github.com/luanruisong/tssh/store" 12 | ) 13 | 14 | type ( 15 | CmdSSHConfig struct { 16 | Name string 17 | Port int 18 | Pwd string 19 | PrivateKeyPath string 20 | } 21 | ) 22 | 23 | func ParseArgs(args []string) *CmdSSHConfig { 24 | var ( 25 | fs = flag.NewFlagSet("set", flag.ExitOnError) 26 | n = fs.String("n", "", "set name in (-a|-s)") 27 | p = fs.String("p", "", "set password in (-a|-s)") 28 | P = fs.Int("P", 22, "set port in (-a|-s)") 29 | k = fs.String("k", "", "set private_key path in (-a|-s)") 30 | ) 31 | fs.Parse(args) 32 | return &CmdSSHConfig{ 33 | Name: *n, 34 | Port: *P, 35 | Pwd: *p, 36 | PrivateKeyPath: *k, 37 | } 38 | } 39 | 40 | func GetUserAndHost(a string) (string, string) { 41 | if len(a) > 0 { 42 | if idx := strings.Index(a, "@"); idx > 0 { 43 | return a[:idx], a[idx+1:] 44 | } 45 | } 46 | return "", "" 47 | } 48 | 49 | func Add(body string, args []string) { 50 | addOrSave(body, args, true) 51 | } 52 | 53 | func Save(body string, args []string) { 54 | addOrSave(body, args, false) 55 | } 56 | 57 | func List() { 58 | list := store.ListConfig() 59 | if len(list) == 0 { 60 | fmt.Println("can not get config list") 61 | return 62 | } 63 | prompt := promptui.Select{ 64 | Label: "Connect config ", 65 | Items: list, 66 | Templates: constant.ListTpl, 67 | Size: 20, 68 | } 69 | _, name, err := prompt.Run() 70 | if err != nil { 71 | fmt.Println("error", err.Error()) 72 | return 73 | } 74 | if conn := store.GetConfig(name); conn != nil { 75 | if err := conn.Conn(); err != nil { 76 | fmt.Println(err) 77 | } 78 | } 79 | } 80 | 81 | func Conn(name string) { 82 | if len(name) == 0 { 83 | List() 84 | return 85 | } 86 | batch := store.GetBatchConfig() 87 | info := batch.Get(name) 88 | if info == nil { 89 | fmt.Println("can not find config", name) 90 | return 91 | } 92 | if err := info.Conn(); err != nil { 93 | fmt.Println(err) 94 | } 95 | } 96 | 97 | func Del(name string) { 98 | if len(name) == 0 { 99 | list := store.ListConfig() 100 | prompt := promptui.Select{ 101 | Label: "Delete config ", 102 | Items: list, 103 | Templates: constant.ListTpl, 104 | Size: 20, 105 | } 106 | var err error 107 | _, name, err = prompt.Run() 108 | if err != nil { 109 | fmt.Println("error", err.Error()) 110 | return 111 | } 112 | } 113 | if err := store.Del(name); err != nil { 114 | fmt.Println(err) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /cmd/internal.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "os" 8 | "strings" 9 | 10 | "github.com/manifoldco/promptui" 11 | 12 | "github.com/luanruisong/tssh/store" 13 | ) 14 | 15 | var ( 16 | validateFunc = func(input string) error { 17 | g := store.GetBatchConfig() 18 | if g.Get(input) == nil { 19 | return errors.New("can not get config") 20 | } 21 | return nil 22 | } 23 | 24 | validateTpl = &promptui.PromptTemplates{ 25 | Prompt: "{{ . }} ", 26 | Valid: "{{ . | green }} ", 27 | Invalid: "{{ . | red }} ", 28 | Success: "{{ . | bold }} ", 29 | } 30 | ) 31 | 32 | func addOrSave(body string, args []string, isAdd bool) { 33 | var err error 34 | if len(body) == 0 { 35 | prompt := promptui.Prompt{ 36 | Label: "please input {user@host}", 37 | Templates: validateTpl, 38 | Validate: func(input string) error { 39 | if strings.Index(input, "@") < 0 { 40 | return fmt.Errorf("con not decode:%s", input) 41 | } 42 | x := strings.Split(input, "@") 43 | if len(x) != 2 { 44 | return fmt.Errorf("con not decode:%s", input) 45 | } 46 | if len(x[0]) == 0 || len(x[1]) == 0 { 47 | return fmt.Errorf("user && ip required") 48 | } 49 | if address := net.ParseIP(x[1]); address == nil { 50 | return fmt.Errorf("con not decode ip:%s", x[1]) 51 | } 52 | return nil 53 | }, 54 | } 55 | body, err = prompt.Run() 56 | if err != nil { 57 | fmt.Println(err.Error()) 58 | return 59 | } 60 | } 61 | //获取添加/覆盖配置需要的参数 62 | config := ParseArgs(args) 63 | //检查别名输入情况 64 | if len(config.Name) == 0 { 65 | prompt := promptui.Prompt{ 66 | Label: "please input alias name", 67 | Templates: validateTpl, 68 | } 69 | config.Name, err = prompt.Run() 70 | if err != nil { 71 | fmt.Println(err.Error()) 72 | return 73 | } 74 | if len(config.Name) == 0 { 75 | config.Name = body 76 | } 77 | } 78 | if isAdd { 79 | if store.ConfigExists(config.Name) { 80 | fmt.Println("config", config.Name, "exists") 81 | return 82 | } 83 | } 84 | //检查密码和密钥输入情况 85 | if len(config.Pwd) == 0 && len(config.PrivateKeyPath) == 0 { 86 | prompt := promptui.Prompt{ 87 | Label: "please input passworde", 88 | Templates: validateTpl, 89 | Validate: func(input string) error { 90 | if len(input) == 0 { 91 | return fmt.Errorf("pwd required") 92 | } 93 | return nil 94 | }, 95 | } 96 | config.Pwd, err = prompt.Run() 97 | if err != nil { 98 | fmt.Println(err.Error()) 99 | return 100 | } 101 | } 102 | user, host := GetUserAndHost(body) 103 | if len(user) == 0 || len(host) == 0 { 104 | fmt.Println("user and host required") 105 | return 106 | } 107 | var ( 108 | privateKey []byte 109 | ) 110 | if len(config.PrivateKeyPath) > 0 { 111 | privateKey, err = os.ReadFile(config.PrivateKeyPath) 112 | if err != nil { 113 | fmt.Println(err.Error()) 114 | return 115 | } 116 | } 117 | 118 | if err = store.NewConfig(config.Name, host, user, config.Pwd, privateKey, config.Port).Save(); err != nil { 119 | fmt.Println(err) 120 | } 121 | return 122 | } 123 | -------------------------------------------------------------------------------- /ssh/ssh.go: -------------------------------------------------------------------------------- 1 | package ssh 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "time" 8 | 9 | "github.com/containerd/console" 10 | "github.com/manifoldco/promptui" 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | func Connect(ip string, port int, cfg *ssh.ClientConfig) (*ssh.Client, error) { 15 | addr := fmt.Sprintf("%s:%d", ip, port) 16 | sshClient, err := ssh.Dial("tcp", addr, cfg) 17 | if err != nil { 18 | return nil, err 19 | } 20 | return sshClient, nil 21 | } 22 | 23 | func RunTerminal(c *ssh.Client, in io.Reader, stdOut, stdErr io.Writer) error { 24 | session, err := c.NewSession() 25 | if err != nil { 26 | return err 27 | } 28 | defer session.Close() 29 | session.Stdout = stdOut 30 | session.Stderr = stdErr 31 | session.Stdin = in 32 | var ( 33 | current = console.Current() 34 | ws console.WinSize 35 | ) 36 | 37 | if err = current.SetRaw(); err != nil { 38 | return err 39 | } 40 | // 确保在函数返回时恢复终端状态 41 | defer current.Reset() 42 | 43 | if ws, err = current.Size(); err != nil { 44 | return err 45 | } 46 | 47 | // Set up terminal modes 48 | modes := ssh.TerminalModes{ 49 | ssh.ECHO: 1, //打开回显 50 | ssh.TTY_OP_ISPEED: 14400, //输入速率 14.4kbaud 51 | ssh.TTY_OP_OSPEED: 14400, //输出速率 14.4kbaud 52 | ssh.VSTATUS: 1, 53 | } 54 | 55 | //Request pseudo terminal 56 | if err = session.RequestPty("xterm-256color", int(ws.Height), int(ws.Width), modes); err != nil { 57 | return err 58 | } 59 | 60 | // 监听窗口大小变化 61 | stopResize := listenWindowResize(session, current) 62 | defer stopResize() 63 | 64 | if err = session.Shell(); err != nil { 65 | return err 66 | } 67 | return session.Wait() 68 | } 69 | 70 | func PwdCfg(user, pwd string) *ssh.ClientConfig { 71 | return cfg(user, ssh.Password(pwd)) 72 | } 73 | 74 | func PkCfg(user string, pemBytes []byte) (*ssh.ClientConfig, error) { 75 | signer, err := ssh.ParsePrivateKey(pemBytes) 76 | if err != nil { 77 | return nil, fmt.Errorf("parsing plain private key failed: %v", err) 78 | } 79 | // 使用 RetryableAuthMethod 包装 publickey,支持 partial success 后继续认证 80 | pkAuth := ssh.RetryableAuthMethod(ssh.PublicKeys(signer), 3) 81 | return cfg(user, pkAuth), nil 82 | } 83 | 84 | func cfg(user string, auth ...ssh.AuthMethod) *ssh.ClientConfig { 85 | // keyboard-interactive 认证,用于 2FA/OTP 等场景 86 | kbAuth := ssh.RetryableAuthMethod(ssh.KeyboardInteractive(keyboardInteractiveChallenge), 3) 87 | c := &ssh.ClientConfig{ 88 | User: user, 89 | Auth: append(auth, kbAuth), 90 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 91 | Timeout: 10 * time.Second, 92 | } 93 | return c 94 | } 95 | 96 | func keyboardInteractiveChallenge(name, instruction string, questions []string, echos []bool) (answers []string, err error) { 97 | // 显示服务器发送的指令信息 98 | if len(instruction) > 0 { 99 | fmt.Println(instruction) 100 | } 101 | 102 | answers = make([]string, len(questions)) 103 | for i, question := range questions { 104 | if len(question) == 0 { 105 | answers[i] = "" 106 | continue 107 | } 108 | 109 | prompt := promptui.Prompt{ 110 | Label: strings.TrimSpace(question), 111 | Templates: &promptui.PromptTemplates{ 112 | Prompt: "{{ . }} ", 113 | Valid: "{{ . | green }} ", 114 | Success: "{{ . | green }} ", 115 | }, 116 | // 如果 echos[i] 为 false,则隐藏输入(如密码/OTP) 117 | Mask: func() rune { 118 | if i < len(echos) && !echos[i] { 119 | return '*' 120 | } 121 | return 0 122 | }(), 123 | } 124 | answers[i], err = prompt.Run() 125 | if err != nil { 126 | return nil, err 127 | } 128 | } 129 | return answers, nil 130 | } 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. --------------------------------------------------------------------------------