├── .editorconfig ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── assets └── sshw-demo.gif ├── client.go ├── cmd └── sshw │ └── main.go ├── config.go ├── go.mod ├── go.sum └── log.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [Makefile] 12 | indent_style = tab 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.go] 18 | indent_style = tab 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | timezone: "Asia/Shanghai" 13 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | - feature/* 9 | - bugfix/* 10 | pull_request: 11 | branches: 12 | - master 13 | - develop 14 | workflow_dispatch: 15 | 16 | jobs: 17 | build: 18 | name: Build 19 | runs-on: ${{ matrix.os }} 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest, windows-latest, macos-latest] 23 | go-version: ['1.23'] 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: ${{ matrix.go-version }} 33 | check-latest: true 34 | 35 | - name: Get dependencies 36 | run: go mod download 37 | 38 | - name: Verify dependencies 39 | run: go mod verify 40 | 41 | - name: Build 42 | run: go build -v ./cmd/sshw/main.go 43 | 44 | - name: Run tests 45 | run: go test -v ./... 46 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [master, develop] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [master, develop] 20 | schedule: 21 | - cron: "22 15 * * 0" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["go"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v3 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create release and upload binaries 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Fetch all tags 20 | run: git fetch --force --tags 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: 1.23 25 | check-latest: true 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v6 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | with: 31 | distribution: goreleaser 32 | version: latest 33 | args: release --clean 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | /build 8 | 9 | # Test binary, build with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Misc 16 | /dist 17 | .idea 18 | 19 | !.goreleaser.yml 20 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | 3 | 4 | project_name: sshw 5 | 6 | metadata: 7 | homepage: https://github.com/sshw/sshw 8 | license: MIT 9 | maintainers: 10 | - "yinheli " 11 | 12 | builds: 13 | - binary: sshw 14 | 15 | main: ./cmd/sshw/main.go 16 | env: 17 | - CGO_ENABLED=0 18 | flags: 19 | - -trimpath 20 | ldflags: 21 | - -s -w -X main.Build={{.Version}} 22 | 23 | goos: 24 | - windows 25 | - darwin 26 | - linux 27 | - freebsd 28 | - openbsd 29 | - solaris 30 | goarch: 31 | - amd64 32 | - 386 33 | - arm 34 | - arm64 35 | - mips 36 | - mipsle 37 | - mips64 38 | - mips64le 39 | goarm: 40 | - 7 41 | - 6 42 | gomips: 43 | - hardfloat 44 | - softfloat 45 | 46 | ignore: 47 | - goos: darwin 48 | goarch: 386 49 | - goos: openbsd 50 | goarch: arm 51 | 52 | archives: 53 | - id: sshw 54 | name_template: "{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}" 55 | format: tar.gz 56 | format_overrides: 57 | - goos: windows 58 | format: zip 59 | files: 60 | - LICENSE 61 | - README.md 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2025 me@yinheli.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sshw 2 | 3 | ![GitHub](https://img.shields.io/github/license/yinheli/sshw) ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/yinheli/sshw) 4 | 5 | ssh client wrapper for automatic login. 6 | 7 | ![usage](./assets/sshw-demo.gif) 8 | 9 | ## install 10 | 11 | use `go get` 12 | 13 | ``` 14 | go install github.com/yinheli/sshw/cmd/sshw@latest 15 | ``` 16 | 17 | or download binary from [releases](//github.com/yinheli/sshw/releases). 18 | 19 | ## config 20 | 21 | config file load in following order: 22 | 23 | - `~/.sshw` 24 | - `~/.sshw.yml` 25 | - `~/.sshw.yaml` 26 | - `./.sshw` 27 | - `./.sshw.yml` 28 | - `./.sshw.yaml` 29 | 30 | config example: 31 | 32 | 33 | ```yaml 34 | - { name: dev server fully configured, user: appuser, host: 192.168.8.35, port: 22, password: 123456 } 35 | - { name: dev server with key path, user: appuser, host: 192.168.8.35, port: 22, keypath: /root/.ssh/id_rsa } 36 | - { name: dev server with passphrase key, user: appuser, host: 192.168.8.35, port: 22, keypath: /root/.ssh/id_rsa, passphrase: abcdefghijklmn} 37 | - { name: dev server without port, user: appuser, host: 192.168.8.35 } 38 | - { name: dev server without user, host: 192.168.8.35 } 39 | - { name: dev server without password, host: 192.168.8.35 } 40 | - { name: ⚡️ server with emoji name, host: 192.168.8.35 } 41 | - { name: server with alias, alias: dev, host: 192.168.8.35 } 42 | - name: server with jump 43 | user: appuser 44 | host: 192.168.8.35 45 | port: 22 46 | password: 123456 47 | jump: 48 | - user: appuser 49 | host: 192.168.8.36 50 | port: 2222 51 | 52 | 53 | # server group 1 54 | - name: server group 1 55 | children: 56 | - { name: server 1, user: root, host: 192.168.1.2 } 57 | - { name: server 2, user: root, host: 192.168.1.3 } 58 | - { name: server 3, user: root, host: 192.168.1.4 } 59 | 60 | # server group 2 61 | - name: server group 2 62 | children: 63 | - { name: server 1, user: root, host: 192.168.2.2 } 64 | - { name: server 2, user: root, host: 192.168.3.3 } 65 | - { name: server 3, user: root, host: 192.168.4.4 } 66 | ``` 67 | 68 | # callback 69 | 70 | 71 | ```yaml 72 | - name: dev server fully configured 73 | user: appuser 74 | host: 192.168.8.35 75 | port: 22 76 | password: 123456 77 | callback-shells: 78 | - { cmd: 2 } 79 | - { delay: 1500, cmd: 0 } 80 | - { cmd: "echo 1" } 81 | ``` 82 | -------------------------------------------------------------------------------- /assets/sshw-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yinheli/sshw/e4d6352db763421e9325f9944f76de4ecd71de47/assets/sshw-demo.gif -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package sshw 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net" 9 | "os" 10 | "os/user" 11 | "path/filepath" 12 | "strconv" 13 | "strings" 14 | "syscall" 15 | "time" 16 | 17 | "golang.org/x/crypto/ssh" 18 | "golang.org/x/crypto/ssh/terminal" 19 | ) 20 | 21 | var ( 22 | DefaultCiphers = []string{ 23 | "aes128-ctr", 24 | "aes192-ctr", 25 | "aes256-ctr", 26 | "aes128-gcm@openssh.com", 27 | "chacha20-poly1305@openssh.com", 28 | "arcfour256", 29 | "arcfour128", 30 | "arcfour", 31 | "aes128-cbc", 32 | "3des-cbc", 33 | "blowfish-cbc", 34 | "cast128-cbc", 35 | "aes192-cbc", 36 | "aes256-cbc", 37 | } 38 | ) 39 | 40 | type Client interface { 41 | Login() 42 | } 43 | 44 | type defaultClient struct { 45 | clientConfig *ssh.ClientConfig 46 | node *Node 47 | } 48 | 49 | func genSSHConfig(node *Node) *defaultClient { 50 | u, err := user.Current() 51 | if err != nil { 52 | l.Error(err) 53 | return nil 54 | } 55 | 56 | var authMethods []ssh.AuthMethod 57 | 58 | var pemBytes []byte 59 | if node.KeyPath == "" { 60 | pemBytes, err = ioutil.ReadFile(filepath.Join(u.HomeDir, ".ssh/id_rsa")) 61 | } else { 62 | pemBytes, err = ioutil.ReadFile(node.KeyPath) 63 | } 64 | if err != nil { 65 | l.Error(err) 66 | } else { 67 | var signer ssh.Signer 68 | if node.Passphrase != "" { 69 | signer, err = ssh.ParsePrivateKeyWithPassphrase(pemBytes, []byte(node.Passphrase)) 70 | } else { 71 | signer, err = ssh.ParsePrivateKey(pemBytes) 72 | } 73 | if err != nil { 74 | l.Error(err) 75 | } else { 76 | authMethods = append(authMethods, ssh.PublicKeys(signer)) 77 | } 78 | } 79 | 80 | password := node.password() 81 | 82 | if password != nil { 83 | authMethods = append(authMethods, password) 84 | } 85 | 86 | authMethods = append(authMethods, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { 87 | answers := make([]string, 0, len(questions)) 88 | for i, q := range questions { 89 | fmt.Print(q) 90 | if echos[i] { 91 | scan := bufio.NewScanner(os.Stdin) 92 | if scan.Scan() { 93 | answers = append(answers, scan.Text()) 94 | } 95 | err := scan.Err() 96 | if err != nil { 97 | return nil, err 98 | } 99 | } else { 100 | b, err := terminal.ReadPassword(int(syscall.Stdin)) 101 | if err != nil { 102 | return nil, err 103 | } 104 | fmt.Println() 105 | answers = append(answers, string(b)) 106 | } 107 | } 108 | return answers, nil 109 | })) 110 | 111 | config := &ssh.ClientConfig{ 112 | User: node.user(), 113 | Auth: authMethods, 114 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 115 | Timeout: time.Second * 10, 116 | } 117 | 118 | config.SetDefaults() 119 | config.Ciphers = append(config.Ciphers, DefaultCiphers...) 120 | 121 | return &defaultClient{ 122 | clientConfig: config, 123 | node: node, 124 | } 125 | } 126 | 127 | func NewClient(node *Node) Client { 128 | return genSSHConfig(node) 129 | } 130 | 131 | func (c *defaultClient) Login() { 132 | host := c.node.Host 133 | port := strconv.Itoa(c.node.port()) 134 | jNodes := c.node.Jump 135 | 136 | var client *ssh.Client 137 | 138 | if len(jNodes) > 0 { 139 | jNode := jNodes[0] 140 | jc := genSSHConfig(jNode) 141 | proxyClient, err := ssh.Dial("tcp", net.JoinHostPort(jNode.Host, strconv.Itoa(jNode.port())), jc.clientConfig) 142 | if err != nil { 143 | l.Error(err) 144 | return 145 | } 146 | conn, err := proxyClient.Dial("tcp", net.JoinHostPort(host, port)) 147 | if err != nil { 148 | l.Error(err) 149 | return 150 | } 151 | ncc, chans, reqs, err := ssh.NewClientConn(conn, net.JoinHostPort(host, port), c.clientConfig) 152 | if err != nil { 153 | l.Error(err) 154 | return 155 | } 156 | client = ssh.NewClient(ncc, chans, reqs) 157 | } else { 158 | client1, err := ssh.Dial("tcp", net.JoinHostPort(host, port), c.clientConfig) 159 | client = client1 160 | if err != nil { 161 | msg := err.Error() 162 | // use terminal password retry 163 | if strings.Contains(msg, "no supported methods remain") && !strings.Contains(msg, "password") { 164 | fmt.Printf("%s@%s's password:", c.clientConfig.User, host) 165 | var b []byte 166 | b, err = terminal.ReadPassword(int(syscall.Stdin)) 167 | if err == nil { 168 | p := string(b) 169 | if p != "" { 170 | c.clientConfig.Auth = append(c.clientConfig.Auth, ssh.Password(p)) 171 | } 172 | fmt.Println() 173 | client, err = ssh.Dial("tcp", net.JoinHostPort(host, port), c.clientConfig) 174 | } 175 | } 176 | } 177 | if err != nil { 178 | l.Error(err) 179 | return 180 | } 181 | } 182 | defer client.Close() 183 | 184 | l.Infof("connect server ssh -p %d %s@%s version: %s\n", c.node.port(), c.node.user(), host, string(client.ServerVersion())) 185 | 186 | session, err := client.NewSession() 187 | if err != nil { 188 | l.Error(err) 189 | return 190 | } 191 | defer session.Close() 192 | 193 | fd := int(os.Stdin.Fd()) 194 | state, err := terminal.MakeRaw(fd) 195 | if err != nil { 196 | l.Error(err) 197 | return 198 | } 199 | defer terminal.Restore(fd, state) 200 | 201 | //changed fd to int(os.Stdout.Fd()) becaused terminal.GetSize(fd) doesn't work in Windows 202 | //refrence: https://github.com/golang/go/issues/20388 203 | w, h, err := terminal.GetSize(int(os.Stdout.Fd())) 204 | 205 | if err != nil { 206 | l.Error(err) 207 | return 208 | } 209 | 210 | modes := ssh.TerminalModes{ 211 | ssh.ECHO: 1, 212 | ssh.TTY_OP_ISPEED: 14400, 213 | ssh.TTY_OP_OSPEED: 14400, 214 | } 215 | err = session.RequestPty("xterm", h, w, modes) 216 | if err != nil { 217 | l.Error(err) 218 | return 219 | } 220 | 221 | session.Stdout = os.Stdout 222 | session.Stderr = os.Stderr 223 | stdinPipe, err := session.StdinPipe() 224 | if err != nil { 225 | l.Error(err) 226 | return 227 | } 228 | 229 | err = session.Shell() 230 | if err != nil { 231 | l.Error(err) 232 | return 233 | } 234 | 235 | // then callback 236 | for i := range c.node.CallbackShells { 237 | shell := c.node.CallbackShells[i] 238 | time.Sleep(shell.Delay * time.Millisecond) 239 | stdinPipe.Write([]byte(shell.Cmd + "\r")) 240 | } 241 | 242 | // change stdin to user 243 | go func() { 244 | _, err = io.Copy(stdinPipe, os.Stdin) 245 | l.Error(err) 246 | session.Close() 247 | }() 248 | 249 | // interval get terminal size 250 | // fix resize issue 251 | go func() { 252 | var ( 253 | ow = w 254 | oh = h 255 | ) 256 | for { 257 | cw, ch, err := terminal.GetSize(fd) 258 | if err != nil { 259 | break 260 | } 261 | 262 | if cw != ow || ch != oh { 263 | err = session.WindowChange(ch, cw) 264 | if err != nil { 265 | break 266 | } 267 | ow = cw 268 | oh = ch 269 | } 270 | time.Sleep(time.Second) 271 | } 272 | }() 273 | 274 | // send keepalive 275 | go func() { 276 | for { 277 | time.Sleep(time.Second * 10) 278 | client.SendRequest("keepalive@openssh.com", false, nil) 279 | } 280 | }() 281 | 282 | session.Wait() 283 | } 284 | -------------------------------------------------------------------------------- /cmd/sshw/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/manifoldco/promptui" 11 | "github.com/yinheli/sshw" 12 | ) 13 | 14 | const prev = "-parent-" 15 | 16 | var ( 17 | Build = "devel" 18 | V = flag.Bool("version", false, "show version") 19 | H = flag.Bool("help", false, "show help") 20 | S = flag.Bool("s", false, "use local ssh config '~/.ssh/config'") 21 | 22 | log = sshw.GetLogger() 23 | 24 | templates = &promptui.SelectTemplates{ 25 | Label: "✨ {{ . | green}}", 26 | Active: "➤ {{ .Name | cyan }}{{if .Alias}}({{.Alias | yellow}}){{end}} {{if .Host}}{{if .User}}{{.User | faint}}{{`@` | faint}}{{end}}{{.Host | faint}}{{end}}", 27 | Inactive: " {{.Name | faint}}{{if .Alias}}({{.Alias | faint}}){{end}} {{if .Host}}{{if .User}}{{.User | faint}}{{`@` | faint}}{{end}}{{.Host | faint}}{{end}}", 28 | } 29 | ) 30 | 31 | func findAlias(nodes []*sshw.Node, nodeAlias string) *sshw.Node { 32 | for _, node := range nodes { 33 | if node.Alias == nodeAlias { 34 | return node 35 | } 36 | if len(node.Children) > 0 { 37 | return findAlias(node.Children, nodeAlias) 38 | } 39 | } 40 | return nil 41 | } 42 | 43 | func main() { 44 | flag.Parse() 45 | if !flag.Parsed() { 46 | flag.Usage() 47 | return 48 | } 49 | 50 | if *H { 51 | flag.Usage() 52 | return 53 | } 54 | 55 | if *V { 56 | fmt.Println("sshw - ssh client wrapper for automatic login") 57 | fmt.Println(" git version:", Build) 58 | fmt.Println(" go version :", runtime.Version()) 59 | return 60 | } 61 | if *S { 62 | err := sshw.LoadSshConfig() 63 | if err != nil { 64 | log.Error("load ssh config error", err) 65 | os.Exit(1) 66 | } 67 | } else { 68 | err := sshw.LoadConfig() 69 | if err != nil { 70 | log.Error("load config error", err) 71 | os.Exit(1) 72 | } 73 | } 74 | 75 | // login by alias 76 | if len(os.Args) > 1 { 77 | var nodeAlias = os.Args[1] 78 | var nodes = sshw.GetConfig() 79 | var node = findAlias(nodes, nodeAlias) 80 | if node != nil { 81 | client := sshw.NewClient(node) 82 | client.Login() 83 | return 84 | } 85 | } 86 | 87 | node := choose(nil, sshw.GetConfig()) 88 | if node == nil { 89 | return 90 | } 91 | 92 | client := sshw.NewClient(node) 93 | client.Login() 94 | } 95 | 96 | func choose(parent, trees []*sshw.Node) *sshw.Node { 97 | prompt := promptui.Select{ 98 | Label: "select host", 99 | Items: trees, 100 | Templates: templates, 101 | Size: 20, 102 | HideSelected: true, 103 | Searcher: func(input string, index int) bool { 104 | node := trees[index] 105 | content := fmt.Sprintf("%s %s %s", node.Name, node.User, node.Host) 106 | if strings.Contains(input, " ") { 107 | for _, key := range strings.Split(input, " ") { 108 | key = strings.TrimSpace(key) 109 | if key != "" { 110 | if !strings.Contains(content, key) { 111 | return false 112 | } 113 | } 114 | } 115 | return true 116 | } 117 | if strings.Contains(content, input) { 118 | return true 119 | } 120 | return false 121 | }, 122 | } 123 | index, _, err := prompt.Run() 124 | if err != nil { 125 | return nil 126 | } 127 | 128 | node := trees[index] 129 | if len(node.Children) > 0 { 130 | first := node.Children[0] 131 | if first.Name != prev { 132 | first = &sshw.Node{Name: prev} 133 | node.Children = append(node.Children[:0], append([]*sshw.Node{first}, node.Children...)...) 134 | } 135 | return choose(trees, node.Children) 136 | } 137 | 138 | if node.Name == prev { 139 | if parent == nil { 140 | return choose(nil, sshw.GetConfig()) 141 | } 142 | return choose(nil, parent) 143 | } 144 | 145 | return node 146 | } 147 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package sshw 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/user" 8 | "path" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/atrox/homedir" 13 | "github.com/kevinburke/ssh_config" 14 | "golang.org/x/crypto/ssh" 15 | "gopkg.in/yaml.v2" 16 | ) 17 | 18 | type Node struct { 19 | Name string `yaml:"name"` 20 | Alias string `yaml:"alias"` 21 | Host string `yaml:"host"` 22 | User string `yaml:"user"` 23 | Port int `yaml:"port"` 24 | KeyPath string `yaml:"keypath"` 25 | Passphrase string `yaml:"passphrase"` 26 | Password string `yaml:"password"` 27 | CallbackShells []*CallbackShell `yaml:"callback-shells"` 28 | Children []*Node `yaml:"children"` 29 | Jump []*Node `yaml:"jump"` 30 | } 31 | 32 | type CallbackShell struct { 33 | Cmd string `yaml:"cmd"` 34 | Delay time.Duration `yaml:"delay"` 35 | } 36 | 37 | func (n *Node) String() string { 38 | return n.Name 39 | } 40 | 41 | func (n *Node) user() string { 42 | if n.User == "" { 43 | return "root" 44 | } 45 | return n.User 46 | } 47 | 48 | func (n *Node) port() int { 49 | if n.Port <= 0 { 50 | return 22 51 | } 52 | return n.Port 53 | } 54 | 55 | func (n *Node) password() ssh.AuthMethod { 56 | if n.Password == "" { 57 | return nil 58 | } 59 | return ssh.Password(n.Password) 60 | } 61 | 62 | func (n *Node) alias() string { 63 | return n.Alias 64 | } 65 | 66 | var ( 67 | config []*Node 68 | ) 69 | 70 | func GetConfig() []*Node { 71 | return config 72 | } 73 | 74 | func LoadConfig() error { 75 | b, err := LoadConfigBytes(".sshw", ".sshw.yml", ".sshw.yaml") 76 | if err != nil { 77 | return err 78 | } 79 | var c []*Node 80 | err = yaml.Unmarshal(b, &c) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | config = c 86 | 87 | return nil 88 | } 89 | 90 | func LoadSshConfig() error { 91 | u, err := user.Current() 92 | if err != nil { 93 | l.Error(err) 94 | return nil 95 | } 96 | f, _ := os.Open(path.Join(u.HomeDir, ".ssh/config")) 97 | cfg, _ := ssh_config.Decode(f) 98 | var nc []*Node 99 | for _, host := range cfg.Hosts { 100 | alias := fmt.Sprintf("%s", host.Patterns[0]) 101 | hostName, err := cfg.Get(alias, "HostName") 102 | if err != nil { 103 | return err 104 | } 105 | if hostName != "" { 106 | port, _ := cfg.Get(alias, "Port") 107 | if port == "" { 108 | port = "22" 109 | } 110 | var c = new(Node) 111 | c.Name = alias 112 | c.Alias = alias 113 | c.Host = hostName 114 | c.User, _ = cfg.Get(alias, "User") 115 | c.Port, _ = strconv.Atoi(port) 116 | keyPath, _ := cfg.Get(alias, "IdentityFile") 117 | c.KeyPath, _ = homedir.Expand(keyPath) 118 | nc = append(nc, c) 119 | // fmt.Println(c.Alias, c.Host, c.User, c.Port, c.KeyPath) 120 | } 121 | } 122 | config = nc 123 | return nil 124 | } 125 | 126 | func LoadConfigBytes(names ...string) ([]byte, error) { 127 | u, err := user.Current() 128 | if err != nil { 129 | return nil, err 130 | } 131 | // homedir 132 | for i := range names { 133 | sshw, err := ioutil.ReadFile(path.Join(u.HomeDir, names[i])) 134 | if err == nil { 135 | return sshw, nil 136 | } 137 | } 138 | // relative 139 | for i := range names { 140 | sshw, err := ioutil.ReadFile(names[i]) 141 | if err == nil { 142 | return sshw, nil 143 | } 144 | } 145 | return nil, err 146 | } 147 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yinheli/sshw 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/atrox/homedir v1.0.0 7 | github.com/kevinburke/ssh_config v1.2.0 8 | github.com/manifoldco/promptui v0.9.0 9 | golang.org/x/crypto v0.31.0 10 | gopkg.in/yaml.v2 v2.4.0 11 | ) 12 | 13 | require ( 14 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 15 | golang.org/x/sys v0.31.0 // indirect 16 | golang.org/x/term v0.30.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atrox/homedir v1.0.0 h1:99Vwk+XECZTDLaAPeMj7vF9JMNcVarWddqPeyDzJT5E= 2 | github.com/atrox/homedir v1.0.0/go.mod h1:ZKVEIDNKscX8qV1TyrwLP+ayjv3XQO7wbVmc5EW00A8= 3 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 4 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 5 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 6 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 7 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 8 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 9 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 10 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 11 | github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= 12 | github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= 13 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 14 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 15 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 16 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 17 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 18 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 19 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 20 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 21 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 22 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 23 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 26 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 27 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 28 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package sshw 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | type Logger interface { 10 | Info(args ...interface{}) 11 | Infof(format string, args ...interface{}) 12 | Error(args ...interface{}) 13 | Errorf(format string, args ...interface{}) 14 | } 15 | 16 | type logger struct{} 17 | 18 | var ( 19 | l Logger = &logger{} 20 | stdlog = log.New(os.Stdout, "[sshw] ", log.LstdFlags) 21 | ) 22 | 23 | func GetLogger() Logger { 24 | return l 25 | } 26 | 27 | func SetLogger(logger Logger) { 28 | l = logger 29 | } 30 | 31 | func (l *logger) Info(args ...interface{}) { 32 | l.println("[info]", args...) 33 | } 34 | 35 | func (l *logger) Infof(format string, args ...interface{}) { 36 | l.printlnf("[info]", format, args...) 37 | } 38 | 39 | func (l *logger) Error(args ...interface{}) { 40 | l.println("[error]", args...) 41 | } 42 | 43 | func (l *logger) Errorf(format string, args ...interface{}) { 44 | l.printlnf("[level]", format, args...) 45 | } 46 | 47 | func (l *logger) println(level string, args ...interface{}) { 48 | stdlog.Println(level, fmt.Sprintln(args...)) 49 | } 50 | 51 | func (l *logger) printlnf(level string, format string, args ...interface{}) { 52 | stdlog.Println(level, fmt.Sprintf(format, args...)) 53 | } 54 | --------------------------------------------------------------------------------