├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── manssh │ ├── actions.go │ ├── commands.go │ ├── kv_flag.go │ ├── main.go │ └── print.go ├── go.mod ├── go.sum ├── manssh.go ├── manssh_test.go ├── models.go ├── screenshot └── manssh.gif └── utils ├── utils.go └── utils_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: test & build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/setup-go@v2 10 | - uses: actions/checkout@v2 11 | - name: test 12 | run: go test ./... 13 | - name: build 14 | run: go build ./cmd/manssh 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 0 20 | - 21 | name: Set up Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: 1.17 25 | - 26 | name: Build 27 | run: go build 28 | - 29 | name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v2 31 | with: 32 | # either 'goreleaser' (default) or 'goreleaser-pro' 33 | distribution: goreleaser 34 | version: latest 35 | args: release --rm-dist 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | cmd/manssh/manssh 16 | vendor 17 | .idea 18 | /manssh 19 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | main: ./cmd/manssh 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | archives: 16 | - replacements: 17 | darwin: Darwin 18 | linux: Linux 19 | windows: Windows 20 | 386: i386 21 | amd64: x86_64 22 | checksum: 23 | name_template: 'checksums.txt' 24 | snapshot: 25 | name_template: "{{ .Tag }}-next" 26 | changelog: 27 | sort: asc 28 | filters: 29 | exclude: 30 | - '^docs:' 31 | - '^test:' 32 | brews: 33 | - tap: 34 | owner: xwjdsh 35 | name: homebrew-tap 36 | commit_author: 37 | name: Wendell Sun 38 | email: iwendellsun@gmail.com 39 | homepage: "https://github.com/xwjdsh/manssh" 40 | description: "Manage your ssh alias configs easily" 41 | license: "MIT" 42 | install: | 43 | bin.install "manssh" 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.9.x 4 | - 1.10.x 5 | 6 | env: 7 | - DEP_VERSION="0.4.1" 8 | 9 | before_install: 10 | # Download the binary to bin folder in $GOPATH 11 | - curl -L -s https://github.com/golang/dep/releases/download/v${DEP_VERSION}/dep-linux-amd64 -o $GOPATH/bin/dep 12 | # Make the binary executable 13 | - chmod +x $GOPATH/bin/dep 14 | 15 | install: 16 | - dep ensure 17 | 18 | after_success: 19 | - test "$TRAVIS_GO_VERSION" = "1.8" -a -n "$TRAVIS_TAG" && curl -sL https://git.io/goreleaser | bash 20 | 21 | deployment: 22 | tag: 23 | tag: /v[0-9]+(\.[0-9]+)*(-.*)*/ 24 | owner: xwjdsh 25 | commands: 26 | - curl -sL https://git.io/goreleaser | bash 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17 as builder 2 | ARG VERSION 3 | WORKDIR /go/src/github.com/xwjdsh/manssh 4 | COPY . . 5 | RUN go build -a -installsuffix cgo -ldflags "-X main.version=${VERSION}" ./cmd/manssh 6 | 7 | FROM alpine:3.15 8 | LABEL maintainer="iwendellsun@gmail.com" 9 | WORKDIR / 10 | COPY --from=builder /go/src/github.com/xwjdsh/manssh/manssh . 11 | ENTRYPOINT ["/manssh"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Wendell Sun 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build ./cmd/manssh 3 | 4 | docker-build: 5 | docker build --build-arg VERSION=`git describe --tags` -t wendellsun/manssh . 6 | 7 | docker-push: 8 | docker push wendellsun/manssh 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # manssh 2 | 3 | [![Release](https://img.shields.io/github/release/xwjdsh/manssh.svg?style=flat-square)](https://github.com/xwjdsh/manssh/releases/latest) 4 | [![Build Status](https://travis-ci.org/xwjdsh/manssh.svg?branch=master)](https://travis-ci.org/xwjdsh/manssh) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/xwjdsh/manssh)](https://goreportcard.com/report/github.com/xwjdsh/manssh) 6 | [![GoCover.io](https://img.shields.io/badge/gocover.io-89.0%25-green.svg)](https://gocover.io/github.com/xwjdsh/manssh) 7 | [![GoDoc](https://godoc.org/github.com/xwjdsh/manssh?status.svg)](https://godoc.org/github.com/xwjdsh/manssh) 8 | [![DUB](https://img.shields.io/dub/l/vibe-d.svg)](https://github.com/xwjdsh/manssh/blob/master/LICENSE) 9 | 10 | manssh is a command line tool for managing your ssh alias config easily, inspired by [storm](https://github.com/emre/storm) project, powered by Go. 11 | 12 | Note:
13 | This project is actually a simple glue project, the most complex and core parsing ssh config file logic implements by [ssh_config](https://github.com/kevinburke/ssh_config), I didn't do much.
14 | At first it was just a imitation of [storm](https://github.com/emre/storm), now it has become a little different. 15 | 16 | ![](https://raw.githubusercontent.com/xwjdsh/manssh/master/screenshot/manssh.gif) 17 | 18 | ## Feature 19 | 20 | * No dependence. 21 | * Add, list, query, delete ssh alias record. 22 | * Backup ssh config. 23 | * [Support Include directive.](#for-include-directive) 24 | 25 | ## Install 26 | 27 | #### Go 28 | Before 1.17 29 | ```shell 30 | go get -u github.com/xwjdsh/manssh/cmd/manssh 31 | ``` 32 | 1.17 or higher 33 | ``` 34 | go install github.com/xwjdsh/manssh/cmd/manssh 35 | ``` 36 | 37 | #### Homebrew 38 | ```shell 39 | brew tap xwjdsh/tap 40 | brew install xwjdsh/tap/manssh 41 | ``` 42 | 43 | #### Docker 44 | ```shell 45 | alias manssh='docker run -t --rm -v ~/.ssh/config:/root/.ssh/config wendellsun/manssh' 46 | ``` 47 | 48 | #### Manual 49 | Download it from [releases](https://github.com/xwjdsh/manssh/releases), and extract it to your `PATH` directory. 50 | 51 | ## Usage 52 | ```text 53 | % manssh 54 | NAME: 55 | manssh - Manage your ssh alias configs easily 56 | 57 | USAGE: 58 | manssh [global options] command [command options] [arguments...] 59 | 60 | VERSION: 61 | master 62 | 63 | COMMANDS: 64 | add, a Add a new SSH alias record 65 | list, l List or query SSH alias records 66 | update, u Update SSH record by specifying alias name 67 | delete, d Delete SSH records by specifying alias names 68 | backup, b Backup SSH config files 69 | help, h Shows a list of commands or help for one command 70 | 71 | GLOBAL OPTIONS: 72 | --file value, -f value (default: "/Users/wendell/.ssh/config") 73 | --help, -h show help 74 | --version, -v print the version 75 | ``` 76 | 77 | ### Add a new alias 78 | ```shell 79 | # manssh add test2 2.2.2.2 80 | # manssh add test1 root@1.1.1.1:77 -c IdentityFile=~/.ssh/wendell 81 | % manssh add test1 root@1.1.1.1:77 -i ~/.ssh/wendell 82 | ✔ alias[test1] added successfully. 83 | 84 | test1 -> root@1.1.1.1:77 85 | identityfile = /Users/wendell/.ssh/wendell 86 | ``` 87 | Username and port config is optional, the username is current login username and port is `22` by default.
88 | Using `-c` to set more config options. For convenience, `-i xxx` can instead of `-c identityfile=xxx`. 89 | 90 | ### List or query alias 91 | ```shell 92 | # manssh list 93 | # manssh list "*" 94 | # manssh list Test -ic 95 | % manssh list test1 77 96 | ✔ Listing 1 records. 97 | 98 | test1 -> root@1.1.1.1:77 99 | identityfile = /Users/wendell/.ssh/wendell 100 | ``` 101 | It will display all alias records If no params offered, or it will using params as keywords query alias records.
102 | If there is a `-it` option, it will ignore case when searching. 103 | 104 | ### Update an alias 105 | ```shell 106 | # manssh update test1 -r test2 107 | # manssh update test1 root@1.1.1.1:22022 108 | % manssh update test1 -i "" -r test3 -c hostname=3.3.3.3 -c port=22022 109 | ✔ alias[test3] updated successfully. 110 | 111 | test3 -> root@3.3.3.3:22022 112 | ``` 113 | Update an existing alias record, it will replace origin user, hostname, port config's if connected string param offered.
114 | You can use `-c` to update single and extra config option, `-c identityfile= -c proxycommand=` will remove `identityfile` and `proxycommand` options.
115 | For convenience, `-i xxx` can instead of `-c identityfile=xxx`
116 | Rename the alias specified by `-r` flag. 117 | 118 | ### Delete one or more alias 119 | ```shell 120 | # manssh delete test1 121 | % manssh delete test1 test2 122 | ✔ alias[test1,test2] deleted successfully. 123 | ``` 124 | 125 | ### Backup ssh config 126 | ``` 127 | % manssh backup ./config_backup 128 | ✔ backup ssh config to [./config_backup] successfully. 129 | ``` 130 | 131 | ## For Include directive 132 | If you use the `Include` directive, there are some extra notes. 133 | 134 | Add `-p`(--path) flag for `list`,`add`,`update`,`delete` command to show the file path where the alias is located, it can also be set by the **MANSSH_SHOW_PATH** environment variable. 135 | 136 |
137 | MANSSH_SHOW_PATH 138 | 139 | Set to `true` to show the file path where the alias is located. Default is `false`. 140 |
141 |
142 | 143 | Add `-ap`(--addpath) flag for `add` command to specify the file path to which the alias is added, it can also be set by the **MANSSH_ADD_PATH** environment variable. 144 | 145 |
146 | MANSSH_ADD_PATH 147 | 148 | This file path indicates to which file to add the alias. Default is the entry config file. 149 |
150 |
151 | 152 | For convenience, you can export these environments in your `.zshrc` or `.bashrc`, 153 | example: 154 | 155 | ```bash 156 | export MANSSH_SHOW_PATH=true 157 | export MANSSH_ADD_PATH=~/.ssh/config.d/temp 158 | ``` 159 | 160 | ## Thanks 161 | * [kevinburke/ssh_config](https://github.com/kevinburke/ssh_config) 162 | * [urfave/cli](https://github.com/urfave/cli) 163 | * [emre/storm](https://github.com/emre/storm) 164 | 165 | ## Licence 166 | [MIT License](https://github.com/xwjdsh/manssh/blob/master/LICENSE) 167 | -------------------------------------------------------------------------------- /cmd/manssh/actions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/xwjdsh/manssh" 12 | "github.com/xwjdsh/manssh/utils" 13 | 14 | "github.com/urfave/cli" 15 | ) 16 | 17 | var ( 18 | path string 19 | ) 20 | 21 | func listCmd(c *cli.Context) error { 22 | hosts, err := manssh.List(path, manssh.ListOption{ 23 | Keywords: c.Args(), 24 | IgnoreCase: c.Bool("ignorecase"), 25 | }) 26 | if err != nil { 27 | fmt.Printf(utils.ErrorFlag) 28 | return cli.NewExitError(err, 1) 29 | } 30 | fmt.Printf("%s total records: %d\n\n", utils.SuccessFlag, len(hosts)) 31 | printHosts(c.Bool("path"), hosts) 32 | return nil 33 | } 34 | 35 | func addCmd(c *cli.Context) error { 36 | // Check arguments count 37 | if err := utils.ArgumentsCheck(c.NArg(), 1, 2); err != nil { 38 | return printErrorWithHelp(c, err) 39 | } 40 | ao := &manssh.AddOption{ 41 | Alias: c.Args().Get(0), 42 | Connect: c.Args().Get(1), 43 | Path: c.String("addpath"), 44 | } 45 | if ao.Path != "" { 46 | var err error 47 | if ao.Path, err = filepath.Abs(ao.Path); err != nil { 48 | fmt.Printf(utils.ErrorFlag) 49 | return cli.NewExitError(err, 1) 50 | } 51 | } 52 | if kvConfig := c.Generic("config"); kvConfig != nil { 53 | ao.Config = kvConfig.(*kvFlag).m 54 | } 55 | if ao.Config == nil { 56 | ao.Config = make(map[string]string) 57 | } 58 | 59 | if identityfile := c.String("identityfile"); identityfile != "" { 60 | ao.Config["identityfile"] = identityfile 61 | } 62 | 63 | if len(ao.Config) == 0 && ao.Connect == "" { 64 | return printErrorWithHelp(c, errors.New("param error")) 65 | } 66 | 67 | host, err := manssh.Add(path, ao) 68 | if err != nil { 69 | fmt.Printf(utils.ErrorFlag) 70 | return cli.NewExitError(err, 1) 71 | } 72 | fmt.Printf("%s added successfully\n", utils.SuccessFlag) 73 | if host != nil { 74 | fmt.Println() 75 | printHost(c.Bool("path"), host) 76 | } 77 | return nil 78 | } 79 | 80 | func updateCmd(c *cli.Context) error { 81 | if err := utils.ArgumentsCheck(c.NArg(), 1, 2); err != nil { 82 | return printErrorWithHelp(c, err) 83 | } 84 | uo := &manssh.UpdateOption{ 85 | Alias: c.Args().Get(0), 86 | Connect: c.Args().Get(1), 87 | NewAlias: c.String("rename"), 88 | } 89 | if kvConfig := c.Generic("config"); kvConfig != nil { 90 | uo.Config = kvConfig.(*kvFlag).m 91 | } 92 | if uo.Config == nil { 93 | uo.Config = make(map[string]string) 94 | } 95 | 96 | if identityfile := c.String("identityfile"); identityfile != "" || c.IsSet("identityfile") { 97 | uo.Config["identityfile"] = identityfile 98 | } 99 | if !uo.Valid() { 100 | return cli.NewExitError("the update option is invalid", 1) 101 | } 102 | 103 | host, err := manssh.Update(path, uo) 104 | if err != nil { 105 | fmt.Printf(utils.ErrorFlag) 106 | return cli.NewExitError(err, 1) 107 | } 108 | 109 | fmt.Printf("%s updated successfully\n\n", utils.SuccessFlag) 110 | printHost(c.Bool("path"), host) 111 | return nil 112 | } 113 | 114 | func deleteCmd(c *cli.Context) error { 115 | if err := utils.ArgumentsCheck(c.NArg(), 1, -1); err != nil { 116 | return printErrorWithHelp(c, err) 117 | } 118 | hosts, err := manssh.Delete(path, c.Args()...) 119 | if err != nil { 120 | fmt.Printf(utils.ErrorFlag) 121 | return cli.NewExitError(err, 1) 122 | } 123 | fmt.Printf("%s deleted successfully\n\n", utils.SuccessFlag) 124 | printHosts(c.Bool("path"), hosts) 125 | return nil 126 | } 127 | 128 | func backupCmd(c *cli.Context) error { 129 | if err := utils.ArgumentsCheck(c.NArg(), 1, 1); err != nil { 130 | return printErrorWithHelp(c, err) 131 | } 132 | backupPath := c.Args().First() 133 | if err := os.MkdirAll(backupPath, os.ModePerm); err != nil { 134 | return cli.NewExitError(err, 1) 135 | } 136 | 137 | paths, err := manssh.GetFilePaths(path) 138 | if err != nil { 139 | return cli.NewExitError(err, 1) 140 | } 141 | pathDir := filepath.Dir(path) 142 | for _, p := range paths { 143 | bp := backupPath 144 | if p != path && strings.HasPrefix(p, pathDir) { 145 | bp = filepath.Join(bp, strings.Replace(p, pathDir, "", 1)) 146 | if err := os.MkdirAll(filepath.Dir(bp), os.ModePerm); err != nil { 147 | return cli.NewExitError(err, 1) 148 | } 149 | } 150 | if err := exec.Command("cp", p, bp).Run(); err != nil { 151 | return cli.NewExitError(err, 1) 152 | } 153 | } 154 | fmt.Printf("%s backup ssh config to [%s] successfully\n", utils.SuccessFlag, backupPath) 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /cmd/manssh/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/urfave/cli" 4 | 5 | func commands() []cli.Command { 6 | return []cli.Command{ 7 | { 8 | Name: "add", 9 | Usage: "Add a new ssh alias record", 10 | Action: addCmd, 11 | Aliases: []string{"a"}, 12 | Flags: []cli.Flag{ 13 | cli.GenericFlag{Name: "config, c", Value: &kvFlag{}}, 14 | cli.StringFlag{Name: "identityfile, i"}, 15 | cli.StringFlag{Name: "addpath, ap", EnvVar: "MANSSH_ADD_PATH"}, 16 | cli.BoolFlag{Name: "path, p", Usage: "display the file path of the alias", EnvVar: "MANSSH_SHOW_PATH"}, 17 | }, 18 | }, 19 | { 20 | Name: "list", 21 | Usage: "List all or query ssh alias records", 22 | Action: listCmd, 23 | Aliases: []string{"l"}, 24 | Flags: []cli.Flag{ 25 | cli.BoolFlag{Name: "ignorecase, ic", Usage: "ignore case while searching"}, 26 | cli.BoolFlag{Name: "path, p", Usage: "dispay the file path of the alias", EnvVar: "MANSSH_SHOW_PATH"}, 27 | }, 28 | }, 29 | { 30 | Name: "update", 31 | Usage: "Update the specified ssh alias", 32 | Action: updateCmd, 33 | Aliases: []string{"u"}, 34 | Flags: []cli.Flag{ 35 | cli.GenericFlag{Name: "config, c", Value: &kvFlag{}}, 36 | cli.StringFlag{Name: "rename, r"}, 37 | cli.StringFlag{Name: "identityfile, i"}, 38 | cli.BoolFlag{Name: "path, p", Usage: "dispay the file path of the alias", EnvVar: "MANSSH_SHOW_PATH"}, 39 | }, 40 | }, 41 | { 42 | Name: "delete", 43 | Usage: "Delete one or more ssh aliases", 44 | Action: deleteCmd, 45 | Aliases: []string{"d"}, 46 | Flags: []cli.Flag{ 47 | cli.BoolFlag{Name: "path, p", Usage: "dispay the file path of the alias", EnvVar: "MANSSH_SHOW_PATH"}, 48 | }, 49 | }, 50 | { 51 | Name: "backup", 52 | Usage: "Backup SSH config files", 53 | Action: backupCmd, 54 | Aliases: []string{"b"}, 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /cmd/manssh/kv_flag.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // https://github.com/urfave/cli/issues/588 9 | type kvFlag struct { 10 | m map[string]string 11 | } 12 | 13 | func (kv *kvFlag) Set(value string) error { 14 | if value == "" { 15 | return nil 16 | } 17 | if kv.m == nil { 18 | kv.m = map[string]string{} 19 | } 20 | parts := strings.Split(value, "=") 21 | if len(parts) != 2 || parts[0] == "" { 22 | return fmt.Errorf("flag param(%s) parse error", value) 23 | } 24 | kv.m[parts[0]] = parts[1] 25 | return nil 26 | } 27 | 28 | func (kv *kvFlag) String() string { 29 | if kv == nil { 30 | return "" 31 | } 32 | 33 | item := make([]string, len(kv.m)) 34 | for k, v := range kv.m { 35 | item = append(item, k+"="+v) 36 | } 37 | return strings.Join(item, ",") 38 | } 39 | -------------------------------------------------------------------------------- /cmd/manssh/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/urfave/cli" 9 | 10 | "github.com/xwjdsh/manssh/utils" 11 | ) 12 | 13 | var ( 14 | version = "master" 15 | ) 16 | 17 | func main() { 18 | app := cli.NewApp() 19 | app.Usage = "Manage your ssh alias configs easily" 20 | app.Version = version 21 | app.Flags = flags() 22 | app.Commands = commands() 23 | if err := app.Run(os.Args); err != nil { 24 | log.Fatal(err) 25 | } 26 | } 27 | 28 | func flags() []cli.Flag { 29 | return []cli.Flag{ 30 | cli.StringFlag{Name: "file, f", Value: fmt.Sprintf("%s/.ssh/config", utils.GetHomeDir()), Destination: &path}, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cmd/manssh/print.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/xwjdsh/manssh" 9 | "github.com/xwjdsh/manssh/utils" 10 | "github.com/xwjdsh/ssh_config" 11 | 12 | "github.com/fatih/color" 13 | "github.com/urfave/cli" 14 | ) 15 | 16 | func printErrorWithHelp(c *cli.Context, err error) error { 17 | if err := cli.ShowSubcommandHelp(c); err != nil { 18 | return err 19 | } 20 | fmt.Println() 21 | return cli.NewExitError(err, 1) 22 | } 23 | 24 | func printHosts(showPath bool, hosts []*manssh.HostConfig) { 25 | var aliases []string 26 | var noConnectAliases []string 27 | hostMap := map[string]*manssh.HostConfig{} 28 | 29 | for _, host := range hosts { 30 | hostMap[host.Alias] = host 31 | if host.Display() { 32 | aliases = append(aliases, host.Alias) 33 | } else { 34 | noConnectAliases = append(noConnectAliases, host.Alias) 35 | } 36 | } 37 | 38 | sort.Strings(aliases) 39 | for _, alias := range aliases { 40 | printHost(showPath, hostMap[alias]) 41 | } 42 | 43 | sort.Strings(noConnectAliases) 44 | for _, alias := range noConnectAliases { 45 | printHost(showPath, hostMap[alias]) 46 | } 47 | } 48 | 49 | func printHost(showPath bool, host *manssh.HostConfig) { 50 | fmt.Printf("\t%s", color.MagentaString(host.Alias)) 51 | if showPath && len(host.PathMap) > 0 { 52 | 53 | var paths []string 54 | for path := range host.PathMap { 55 | if homeDir := utils.GetHomeDir(); strings.HasPrefix(path, homeDir) { 56 | path = strings.Replace(path, homeDir, "~", 1) 57 | } 58 | paths = append(paths, path) 59 | } 60 | sort.Strings(paths) 61 | fmt.Printf("(%s)", strings.Join(paths, " ")) 62 | } 63 | if connect := host.ConnectionStr(); connect != "" { 64 | fmt.Printf(" -> %s", connect) 65 | } 66 | fmt.Println() 67 | for _, key := range utils.SortKeys(host.OwnConfig) { 68 | value := host.OwnConfig[key] 69 | if value == "" { 70 | continue 71 | } 72 | key = ssh_config.GetCanonicalCase(key) 73 | color.Cyan("\t %s = %s\n", key, value) 74 | } 75 | for _, key := range utils.SortKeys(host.ImplicitConfig) { 76 | value := host.ImplicitConfig[key] 77 | if value == "" { 78 | continue 79 | } 80 | key = ssh_config.GetCanonicalCase(key) 81 | fmt.Printf("\t %s = %s\n", key, value) 82 | } 83 | fmt.Println() 84 | } 85 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/xwjdsh/manssh 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/fatih/color v1.6.0 7 | github.com/stretchr/testify v1.2.2 8 | github.com/urfave/cli v1.20.0 9 | github.com/xwjdsh/ssh_config v0.0.0-20220211060505-936c636e637e 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.0 // indirect 14 | github.com/mattn/go-colorable v0.0.9 // indirect 15 | github.com/mattn/go-isatty v0.0.3 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | golang.org/x/sys v0.0.0-20180202135801-37707fdb30a5 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fatih/color v1.6.0 h1:66qjqZk8kalYAvDRtM1AdAJQI0tj4Wrue3Eq3B3pmFU= 4 | github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 5 | github.com/kevinburke/ssh_config v1.1.0 h1:pH/t1WS9NzT8go394IqZeJTMHVm6Cr6ZJ6AQ+mdNo/o= 6 | github.com/kevinburke/ssh_config v1.1.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 7 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 8 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 9 | github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= 10 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 14 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 15 | github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= 16 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 17 | github.com/xwjdsh/ssh_config v0.0.0-20220211060505-936c636e637e h1:WO3MRhEvVnOPqXs97VpmnEqHksO7BMdhhOt/hfONBYM= 18 | github.com/xwjdsh/ssh_config v0.0.0-20220211060505-936c636e637e/go.mod h1:NuVEthmy5UVVrQFoZDXmf5lGTZMQ0IF0vHTzh21y6vs= 19 | golang.org/x/sys v0.0.0-20180202135801-37707fdb30a5 h1:MF92a0wJ3gzSUVBpjcwdrDr5+klMFRNEEu6Mev4n00I= 20 | golang.org/x/sys v0.0.0-20180202135801-37707fdb30a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 21 | -------------------------------------------------------------------------------- /manssh.go: -------------------------------------------------------------------------------- 1 | package manssh 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "github.com/xwjdsh/manssh/utils" 11 | 12 | "github.com/xwjdsh/ssh_config" 13 | ) 14 | 15 | func writeConfig(p string, cfg *ssh_config.Config) error { 16 | return ioutil.WriteFile(p, []byte(cfg.String()), 0644) 17 | } 18 | 19 | func readFile(p string) (*ssh_config.Config, error) { 20 | f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE, 0600) 21 | if err != nil { 22 | return nil, err 23 | } 24 | return ssh_config.Decode(f) 25 | } 26 | 27 | func deleteHostFromConfig(config *ssh_config.Config, host *ssh_config.Host) { 28 | var hs []*ssh_config.Host 29 | for _, h := range config.Hosts { 30 | if h == host { 31 | continue 32 | } 33 | hs = append(hs, h) 34 | } 35 | config.Hosts = hs 36 | } 37 | 38 | func setImplicitConfig(aliasMap map[string]*HostConfig, hc *HostConfig) { 39 | for alias, host := range aliasMap { 40 | if alias == hc.Alias { 41 | continue 42 | } 43 | 44 | if len(hc.OwnConfig) == 0 { 45 | if match, err := path.Match(host.Alias, hc.Alias); err != nil || !match { 46 | continue 47 | } 48 | for k, v := range host.OwnConfig { 49 | if _, ok := hc.ImplicitConfig[k]; !ok { 50 | hc.ImplicitConfig[k] = v 51 | } 52 | } 53 | continue 54 | } 55 | if match, err := path.Match(hc.Alias, host.Alias); err != nil || !match { 56 | continue 57 | } 58 | for k, v := range hc.OwnConfig { 59 | if _, ok := host.OwnConfig[k]; ok { 60 | continue 61 | } 62 | if _, ok := host.ImplicitConfig[k]; !ok { 63 | host.ImplicitConfig[k] = v 64 | } 65 | } 66 | } 67 | } 68 | 69 | func setOwnConfig(aliasMap map[string]*HostConfig, hc *HostConfig, h *ssh_config.Host) { 70 | if host, ok := aliasMap[hc.Alias]; ok { 71 | if _, ok := host.PathMap[hc.Path]; !ok { 72 | host.PathMap[hc.Path] = []*ssh_config.Host{} 73 | } 74 | host.PathMap[hc.Path] = append(host.PathMap[hc.Path], h) 75 | for k, v := range hc.OwnConfig { 76 | if _, ok := host.OwnConfig[k]; !ok { 77 | host.OwnConfig[k] = v 78 | } 79 | } 80 | } else { 81 | aliasMap[hc.Alias] = hc 82 | } 83 | } 84 | 85 | func addHosts(aliasMap map[string]*HostConfig, fp string, hosts ...*ssh_config.Host) { 86 | for _, host := range hosts { 87 | // except implicit `*` 88 | if len(host.Nodes) == 0 { 89 | continue 90 | } 91 | for _, pattern := range host.Patterns { 92 | alias := pattern.String() 93 | hc := NewHostConfig(alias, fp, host) 94 | setImplicitConfig(aliasMap, hc) 95 | 96 | for _, node := range host.Nodes { 97 | if kvNode, ok := node.(*ssh_config.KV); ok { 98 | kvNode.Key = strings.ToLower(kvNode.Key) 99 | if _, ok := hc.ImplicitConfig[kvNode.Key]; !ok { 100 | hc.OwnConfig[kvNode.Key] = kvNode.Value 101 | } 102 | } 103 | } 104 | 105 | setImplicitConfig(aliasMap, hc) 106 | setOwnConfig(aliasMap, hc, host) 107 | } 108 | } 109 | } 110 | 111 | // ParseConfig parse configs from ssh config file, return config object and alias map 112 | func parseConfig(p string) (map[string]*ssh_config.Config, map[string]*HostConfig, error) { 113 | cfg, err := readFile(p) 114 | if err != nil { 115 | return nil, nil, err 116 | } 117 | 118 | aliasMap := map[string]*HostConfig{} 119 | configMap := map[string]*ssh_config.Config{p: cfg} 120 | 121 | for _, host := range cfg.Hosts { 122 | isInclude := false 123 | for _, node := range host.Nodes { 124 | switch t := node.(type) { 125 | case *ssh_config.Include: 126 | isInclude = true 127 | for fp, config := range t.GetFiles() { 128 | configMap[fp] = config 129 | addHosts(aliasMap, fp, config.Hosts...) 130 | } 131 | } 132 | } 133 | if !isInclude { 134 | addHosts(aliasMap, p, host) 135 | } 136 | } 137 | addHosts(aliasMap, p, &ssh_config.Host{ 138 | Patterns: []*ssh_config.Pattern{(&ssh_config.Pattern{}).SetStr("*")}, 139 | Nodes: []ssh_config.Node{ 140 | ssh_config.NewKV("user", utils.GetUsername()), 141 | ssh_config.NewKV("port", "22"), 142 | }, 143 | }) 144 | return configMap, aliasMap, nil 145 | } 146 | 147 | // ListOption options for List 148 | type ListOption struct { 149 | // Keywords set Keyword filter records 150 | Keywords []string 151 | // IgnoreCase ignore case 152 | IgnoreCase bool 153 | } 154 | 155 | // List ssh alias, filter by optional keyword 156 | func List(p string, lo ListOption) ([]*HostConfig, error) { 157 | configMap, aliasMap, err := parseConfig(p) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | var result []*HostConfig 163 | for _, host := range aliasMap { 164 | values := []string{host.Alias} 165 | for _, v := range host.OwnConfig { 166 | values = append(values, v) 167 | } 168 | 169 | if len(lo.Keywords) > 0 && !utils.Query(values, lo.Keywords, lo.IgnoreCase) { 170 | continue 171 | } 172 | result = append(result, host) 173 | } 174 | 175 | // Format 176 | for fp, cfg := range configMap { 177 | if len(cfg.Hosts) > 0 { 178 | if err := writeConfig(fp, cfg); err != nil { 179 | return nil, err 180 | } 181 | } 182 | } 183 | return result, nil 184 | } 185 | 186 | // AddOption options for Add 187 | type AddOption struct { 188 | // Path add path 189 | Path string 190 | // Alias alias 191 | Alias string 192 | // Connect connection string 193 | Connect string 194 | // Config other config 195 | Config map[string]string 196 | } 197 | 198 | // Add ssh host config to ssh config file 199 | func Add(p string, ao *AddOption) (*HostConfig, error) { 200 | if ao.Path == "" { 201 | ao.Path = p 202 | } 203 | 204 | configMap, aliasMap, err := parseConfig(p) 205 | if err != nil { 206 | return nil, err 207 | } 208 | if err := checkAlias(aliasMap, false, ao.Alias); err != nil { 209 | return nil, err 210 | } 211 | 212 | cfg, ok := configMap[ao.Path] 213 | if !ok { 214 | cfg, err = readFile(ao.Path) 215 | if err != nil { 216 | return nil, err 217 | } 218 | } 219 | 220 | // Parse connect string 221 | user, hostname, port := utils.ParseConnect(ao.Connect) 222 | if user != "" { 223 | ao.Config["user"] = user 224 | } 225 | if hostname != "" { 226 | ao.Config["hostname"] = hostname 227 | } 228 | if port != "" { 229 | ao.Config["port"] = port 230 | } 231 | 232 | var nodes []ssh_config.Node 233 | for k, v := range ao.Config { 234 | nodes = append(nodes, ssh_config.NewKV(strings.ToLower(k), v)) 235 | } 236 | 237 | pattern, err := ssh_config.NewPattern(ao.Alias) 238 | if err != nil { 239 | return nil, err 240 | } 241 | 242 | cfg.Hosts = append(cfg.Hosts, &ssh_config.Host{ 243 | Patterns: []*ssh_config.Pattern{pattern}, 244 | Nodes: nodes, 245 | }) 246 | if err := writeConfig(ao.Path, cfg); err != nil { 247 | return nil, err 248 | } 249 | 250 | _, aliasMap, err = parseConfig(p) 251 | if err != nil { 252 | return nil, err 253 | } 254 | return aliasMap[ao.Alias], nil 255 | } 256 | 257 | // UpdateOption options for Update 258 | type UpdateOption struct { 259 | // Alias alias 260 | Alias string 261 | // NewAlias new alias 262 | NewAlias string 263 | // Connect connection string 264 | Connect string 265 | // Config other config 266 | Config map[string]string 267 | } 268 | 269 | // Valid whether the option is valid 270 | func (uo *UpdateOption) Valid() bool { 271 | return uo.NewAlias != "" || uo.Connect != "" || len(uo.Config) > 0 272 | } 273 | 274 | // Update existing record 275 | func Update(p string, uo *UpdateOption) (*HostConfig, error) { 276 | configMap, aliasMap, err := parseConfig(p) 277 | if err != nil { 278 | return nil, err 279 | } 280 | if err := checkAlias(aliasMap, true, uo.Alias); err != nil { 281 | return nil, err 282 | } 283 | 284 | updateHost := aliasMap[uo.Alias] 285 | if uo.NewAlias != "" { 286 | // new alias should not exist 287 | if err := checkAlias(aliasMap, false, uo.NewAlias); err != nil { 288 | return nil, err 289 | } 290 | } else { 291 | uo.NewAlias = uo.Alias 292 | } 293 | 294 | if uo.Connect != "" { 295 | // Parse connect string 296 | user, hostname, port := utils.ParseConnect(uo.Connect) 297 | if user != "" { 298 | uo.Config["user"] = user 299 | } 300 | if hostname != "" { 301 | uo.Config["hostname"] = hostname 302 | } 303 | if port != "" { 304 | uo.Config["port"] = port 305 | } 306 | } 307 | 308 | for k, v := range uo.Config { 309 | if v == "" { 310 | delete(updateHost.OwnConfig, k) 311 | } else { 312 | updateHost.OwnConfig[k] = v 313 | } 314 | } 315 | 316 | for fp, hosts := range updateHost.PathMap { 317 | for i, host := range hosts { 318 | if fp == updateHost.Path { 319 | pattern, _ := ssh_config.NewPattern(uo.NewAlias) 320 | newHost := &ssh_config.Host{ 321 | Patterns: []*ssh_config.Pattern{pattern}, 322 | } 323 | for k, v := range updateHost.OwnConfig { 324 | newHost.Nodes = append(newHost.Nodes, ssh_config.NewKV(k, v)) 325 | } 326 | if len(host.Patterns) == 1 { 327 | if i == 0 { 328 | *host = *newHost 329 | // for implicit "*" 330 | find := false 331 | for _, h := range configMap[fp].Hosts { 332 | if host == h { 333 | find = true 334 | break 335 | } 336 | } 337 | if !find { 338 | newHost.Nodes = []ssh_config.Node{} 339 | for k, v := range uo.Config { 340 | newHost.Nodes = append(newHost.Nodes, ssh_config.NewKV(k, v)) 341 | } 342 | configMap[fp].Hosts = append(configMap[fp].Hosts, newHost) 343 | } 344 | } else { 345 | deleteHostFromConfig(configMap[fp], host) 346 | } 347 | } else { 348 | if i == 0 { 349 | configMap[fp].Hosts = append(configMap[fp].Hosts, newHost) 350 | } 351 | var patterns []*ssh_config.Pattern 352 | for _, pattern := range host.Patterns { 353 | if pattern.String() != uo.NewAlias { 354 | patterns = append(patterns, pattern) 355 | } 356 | } 357 | host.Patterns = patterns 358 | } 359 | } else { 360 | if len(host.Patterns) == 1 { 361 | deleteHostFromConfig(configMap[fp], host) 362 | } else { 363 | var patterns []*ssh_config.Pattern 364 | for _, pattern := range host.Patterns { 365 | if pattern.String() != uo.NewAlias { 366 | patterns = append(patterns, pattern) 367 | } 368 | } 369 | host.Patterns = patterns 370 | } 371 | } 372 | if err := writeConfig(fp, configMap[fp]); err != nil { 373 | return nil, err 374 | } 375 | } 376 | } 377 | _, aliasMap, err = parseConfig(p) 378 | if err != nil { 379 | return nil, err 380 | } 381 | return aliasMap[uo.NewAlias], nil 382 | } 383 | 384 | // Delete existing alias record 385 | func Delete(p string, aliases ...string) ([]*HostConfig, error) { 386 | configMap, aliasMap, err := parseConfig(p) 387 | if err != nil { 388 | return nil, err 389 | } 390 | if err := checkAlias(aliasMap, true, aliases...); err != nil { 391 | return nil, err 392 | } 393 | 394 | var deleteHosts []*HostConfig 395 | for _, alias := range aliases { 396 | deleteHost := aliasMap[alias] 397 | deleteHosts = append(deleteHosts, deleteHost) 398 | for fp, hosts := range deleteHost.PathMap { 399 | for _, host := range hosts { 400 | if len(host.Patterns) == 1 { 401 | deleteHostFromConfig(configMap[fp], host) 402 | } else { 403 | var patterns []*ssh_config.Pattern 404 | for _, pattern := range host.Patterns { 405 | if pattern.String() != alias { 406 | patterns = append(patterns, pattern) 407 | } 408 | } 409 | host.Patterns = patterns 410 | } 411 | } 412 | if err := writeConfig(fp, configMap[fp]); err != nil { 413 | return nil, err 414 | } 415 | } 416 | } 417 | 418 | return deleteHosts, nil 419 | } 420 | 421 | // GetFilePaths get file paths 422 | func GetFilePaths(p string) ([]string, error) { 423 | configMap, _, err := parseConfig(p) 424 | if err != nil { 425 | return nil, err 426 | } 427 | paths := make([]string, 0, len(configMap)) 428 | for path := range configMap { 429 | paths = append(paths, path) 430 | } 431 | return paths, nil 432 | } 433 | 434 | func checkAlias(aliasMap map[string]*HostConfig, expectExist bool, aliases ...string) error { 435 | for _, alias := range aliases { 436 | ok := aliasMap[alias] != nil 437 | if !ok && expectExist { 438 | return fmt.Errorf("alias[%s] not found", alias) 439 | } else if ok && !expectExist { 440 | return fmt.Errorf("alias[%s] already exists", alias) 441 | } 442 | } 443 | return nil 444 | } 445 | -------------------------------------------------------------------------------- /manssh_test.go: -------------------------------------------------------------------------------- 1 | package manssh 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | const ( 14 | mainConfigContent = ` 15 | Include %s/config.d/* 16 | Host home1 17 | hostname 192.168.1.11 18 | Host main1 19 | hostname 192.168.1.10 20 | Host main2 21 | hostname 192.168.1.20 22 | user wen 23 | port 77 24 | Host main3 25 | hostname 192.168.1.30 26 | user ROOT 27 | port 77 28 | ` 29 | testConfigContent = ` 30 | Host * 31 | port 22022 32 | Host test1 33 | hostname 192.168.2.10 34 | user root 35 | port 22 36 | Host test2 main2 37 | hostname 192.168.2.20 38 | port 77 39 | Host Test3 40 | hostname 192.168.2.30 41 | user ROOT 42 | port 77 43 | ` 44 | homeConfigContent = ` 45 | Host home1 46 | hostname 192.168.3.10 47 | user ROOT 48 | port 77 49 | Host home2 50 | hostname 192.168.3.20 51 | user root 52 | port 77 53 | Host home3 54 | hostname 192.168.3.30 55 | user ROOT 56 | port 77 57 | ` 58 | ) 59 | 60 | var ( 61 | configRootDir = filepath.Join(os.TempDir(), "manssh") 62 | mainConfigPath = filepath.Join(configRootDir, "config") 63 | testConfigPath = filepath.Join(configRootDir, "config.d", "test") 64 | homeConfigPath = filepath.Join(configRootDir, "config.d", "home") 65 | ) 66 | 67 | func initConfig() { 68 | _ = os.MkdirAll(configRootDir, os.ModePerm) 69 | _ = os.MkdirAll(filepath.Join(configRootDir, "config.d"), os.ModePerm) 70 | if err := ioutil.WriteFile(mainConfigPath, []byte(fmt.Sprintf(mainConfigContent, configRootDir)), 0644); err != nil { 71 | panic(err) 72 | } 73 | if err := ioutil.WriteFile(testConfigPath, []byte(testConfigContent), 0644); err != nil { 74 | panic(err) 75 | } 76 | if err := ioutil.WriteFile(homeConfigPath, []byte(homeConfigContent), 0644); err != nil { 77 | panic(err) 78 | } 79 | } 80 | 81 | func TestList(t *testing.T) { 82 | initConfig() 83 | defer os.Remove(configRootDir) 84 | 85 | hosts, err := List(mainConfigPath, ListOption{}) 86 | require.Nil(t, err) 87 | require.Equal(t, 10, len(hosts)) 88 | hostMap := map[string]*HostConfig{} 89 | for _, host := range hosts { 90 | hostMap[host.Alias] = host 91 | } 92 | 93 | main2 := hostMap["main2"] 94 | require.NotNil(t, main2) 95 | require.Equal(t, 2, len(main2.OwnConfig)) 96 | require.Equal(t, 1, len(main2.ImplicitConfig)) 97 | require.Empty(t, main2.OwnConfig["port"]) 98 | require.Equal(t, "22022", main2.ImplicitConfig["port"]) 99 | require.Equal(t, "192.168.2.20", main2.OwnConfig["hostname"]) 100 | 101 | // FIXME when use INCLUDE load configs and them have the same alias config, there may be uncertain results here. I'll fix this later. 102 | // home1 := hostMap["home1"] 103 | // require.NotNil(t, home1) 104 | // require.Equal(t, 3, len(home1.OwnConfig)) 105 | // require.Equal(t, 0, len(home1.ImplicitConfig)) 106 | // require.Equal(t, "77", home1.OwnConfig["port"]) 107 | // require.Equal(t, "ROOT", home1.OwnConfig["user"]) 108 | // require.Equal(t, "192.168.3.10", home1.OwnConfig["hostname"]) 109 | 110 | hosts, err = List(mainConfigPath, ListOption{ 111 | Keywords: []string{"Test"}, 112 | }) 113 | require.Nil(t, err) 114 | require.Equal(t, 1, len(hosts)) 115 | 116 | hosts, err = List(mainConfigPath, ListOption{ 117 | Keywords: []string{"Test"}, 118 | IgnoreCase: true, 119 | }) 120 | require.Nil(t, err) 121 | require.Equal(t, 3, len(hosts)) 122 | } 123 | 124 | func TestAdd(t *testing.T) { 125 | initConfig() 126 | defer os.Remove(configRootDir) 127 | 128 | _, err := Add(mainConfigPath, &AddOption{ 129 | Path: testConfigPath, 130 | Alias: "test1", 131 | Connect: "xxx@1.2.3.4:11", 132 | Config: map[string]string{}, 133 | }) 134 | require.NotNil(t, err) 135 | 136 | host, err := Add(mainConfigPath, &AddOption{ 137 | Path: testConfigPath, 138 | Alias: "test4", 139 | Connect: "xxx@1.2.3.4", 140 | Config: map[string]string{}, 141 | }) 142 | require.Nil(t, err) 143 | require.Equal(t, "22022", host.ImplicitConfig["port"]) 144 | require.Equal(t, "1.2.3.4", host.OwnConfig["hostname"]) 145 | require.Equal(t, "xxx", host.OwnConfig["user"]) 146 | 147 | hosts, err := List(mainConfigPath, ListOption{}) 148 | require.Nil(t, err) 149 | require.Equal(t, 11, len(hosts)) 150 | } 151 | 152 | func TestUpdate(t *testing.T) { 153 | initConfig() 154 | defer os.Remove(configRootDir) 155 | 156 | _, err := Update(mainConfigPath, &UpdateOption{ 157 | Alias: "test4", 158 | Connect: "xxx@1.2.3.4:11", 159 | }) 160 | require.NotNil(t, err) 161 | 162 | host, err := Update(mainConfigPath, &UpdateOption{ 163 | Alias: "test1", 164 | NewAlias: "test4", 165 | Connect: "xxx@1.2.3.4:11", 166 | Config: map[string]string{ 167 | "IdentifyFile": "~/.ssh/test4", 168 | }, 169 | }) 170 | require.Nil(t, err) 171 | require.Equal(t, "1.2.3.4", host.OwnConfig["hostname"]) 172 | require.Equal(t, "xxx", host.OwnConfig["user"]) 173 | require.Equal(t, "~/.ssh/test4", host.OwnConfig["identifyfile"]) 174 | require.Equal(t, "22022", host.ImplicitConfig["port"]) 175 | 176 | // FIXME when use INCLUDE load configs and them have the same alias config, there may be uncertain results here. I'll fix this later. 177 | // host, err = Update(mainConfigPath, &UpdateOption{ 178 | // Alias: "home1", 179 | // Connect: "1.2.3.4:11", 180 | // Config: map[string]string{}, 181 | // }) 182 | // require.Nil(t, err) 183 | // require.Equal(t, "1.2.3.4", host.OwnConfig["hostname"]) 184 | // require.Equal(t, "11", host.OwnConfig["port"]) 185 | 186 | hosts, err := List(mainConfigPath, ListOption{}) 187 | require.Nil(t, err) 188 | require.Equal(t, 10, len(hosts)) 189 | hostMap := map[string]*HostConfig{} 190 | for _, host := range hosts { 191 | hostMap[host.Alias] = host 192 | } 193 | require.Nil(t, hostMap["test1"]) 194 | require.NotNil(t, hostMap["test4"]) 195 | } 196 | 197 | func TestDelete(t *testing.T) { 198 | initConfig() 199 | defer os.Remove(configRootDir) 200 | 201 | _, err := Delete(mainConfigPath, "home1", "test1", "main4") 202 | require.NotNil(t, err) 203 | 204 | hosts, err := Delete(mainConfigPath, "home1", "test1", "*") 205 | require.Nil(t, err) 206 | require.Equal(t, 3, len(hosts)) 207 | 208 | hosts, err = List(mainConfigPath, ListOption{}) 209 | require.Nil(t, err) 210 | require.Equal(t, 8, len(hosts)) 211 | hostMap := map[string]*HostConfig{} 212 | for _, host := range hosts { 213 | hostMap[host.Alias] = host 214 | } 215 | require.Nil(t, hostMap["home1"]) 216 | require.Nil(t, hostMap["test1"]) 217 | require.NotNil(t, hostMap["*"]) 218 | require.Equal(t, "22", hostMap["main1"].ImplicitConfig["port"]) 219 | } 220 | 221 | func TestGetFilePaths(t *testing.T) { 222 | initConfig() 223 | defer os.Remove(configRootDir) 224 | paths, err := GetFilePaths(mainConfigPath) 225 | require.Nil(t, err) 226 | require.Equal(t, 3, len(paths)) 227 | } 228 | -------------------------------------------------------------------------------- /models.go: -------------------------------------------------------------------------------- 1 | package manssh 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/xwjdsh/ssh_config" 7 | ) 8 | 9 | // HostConfig struct include alias, connect string and other config 10 | type HostConfig struct { 11 | // Alias alias 12 | Alias string 13 | // Path found in which file 14 | Path string 15 | // PathMap key is file path, value is the alias's hosts 16 | PathMap map[string][]*ssh_config.Host 17 | // OwnConfig own config 18 | OwnConfig map[string]string 19 | // ImplicitConfig implicit config 20 | ImplicitConfig map[string]string 21 | } 22 | 23 | // NewHostConfig new HostConfig 24 | func NewHostConfig(alias, path string, host *ssh_config.Host) *HostConfig { 25 | return &HostConfig{ 26 | Alias: alias, 27 | Path: path, 28 | PathMap: map[string][]*ssh_config.Host{path: {host}}, 29 | OwnConfig: map[string]string{}, 30 | ImplicitConfig: map[string]string{}, 31 | } 32 | } 33 | 34 | // ConnectionStr return the connection string 35 | func (hc *HostConfig) ConnectionStr() string { 36 | if !hc.Display() { 37 | return "" 38 | } 39 | 40 | var ( 41 | user, hostname, port string 42 | ok bool 43 | ) 44 | 45 | if user, ok = hc.OwnConfig["user"]; !ok { 46 | user = hc.ImplicitConfig["user"] 47 | delete(hc.ImplicitConfig, "user") 48 | } else { 49 | delete(hc.OwnConfig, "user") 50 | } 51 | 52 | if hostname, ok = hc.OwnConfig["hostname"]; !ok { 53 | delete(hc.ImplicitConfig, "hostname") 54 | hostname = hc.ImplicitConfig["hostname"] 55 | } else { 56 | delete(hc.OwnConfig, "hostname") 57 | } 58 | 59 | if port, ok = hc.OwnConfig["port"]; !ok { 60 | port = hc.ImplicitConfig["port"] 61 | delete(hc.ImplicitConfig, "port") 62 | } else { 63 | delete(hc.OwnConfig, "port") 64 | } 65 | 66 | return fmt.Sprintf("%s@%s:%s", user, hostname, port) 67 | } 68 | 69 | // Display Whether to display connection string 70 | func (hc *HostConfig) Display() bool { 71 | hostname := hc.OwnConfig["hostname"] 72 | if hostname == "" { 73 | hostname = hc.ImplicitConfig["hostname"] 74 | } 75 | 76 | return hostname != "" 77 | } 78 | -------------------------------------------------------------------------------- /screenshot/manssh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xwjdsh/manssh/a7b987ecdbf187a1b6c02af7018482bdce0ef8be/screenshot/manssh.gif -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/user" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/fatih/color" 12 | ) 13 | 14 | var ( 15 | // SuccessFlag success flag 16 | SuccessFlag = color.GreenString("✔ ") 17 | // ErrorFlag error flag 18 | ErrorFlag = color.RedString("✗ ") 19 | ) 20 | 21 | // ArgumentsCheck check arguments count correctness 22 | func ArgumentsCheck(argCount, min, max int) error { 23 | var err error 24 | if min > 0 && argCount < min { 25 | err = errors.New("too few arguments") 26 | } 27 | if max > 0 && argCount > max { 28 | err = errors.New("too many arguments") 29 | } 30 | return err 31 | } 32 | 33 | // Query values contains keys 34 | func Query(values, keys []string, ignoreCase bool) bool { 35 | contains := func(key string) bool { 36 | if ignoreCase { 37 | key = strings.ToLower(key) 38 | } 39 | for _, value := range values { 40 | if ignoreCase { 41 | value = strings.ToLower(value) 42 | } 43 | if strings.Contains(value, key) { 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | for _, key := range keys { 50 | if contains(key) { 51 | return true 52 | } 53 | } 54 | return false 55 | } 56 | 57 | // GetHomeDir return user's home directory 58 | func GetHomeDir() string { 59 | u, err := user.Current() 60 | if nil == err && u.HomeDir != "" { 61 | return u.HomeDir 62 | } 63 | return os.Getenv("HOME") 64 | } 65 | 66 | // GetUsername return current username 67 | func GetUsername() string { 68 | username := "" 69 | u, err := user.Current() 70 | if err == nil { 71 | username = u.Username 72 | } 73 | return username 74 | } 75 | 76 | // SortKeys sort map keys 77 | func SortKeys(m map[string]string) []string { 78 | var keys []string 79 | for k := range m { 80 | keys = append(keys, k) 81 | } 82 | sort.Strings(keys) 83 | return keys 84 | } 85 | 86 | // ParseConnect parse connect string, format is [user@]host[:port] 87 | func ParseConnect(connect string) (string, string, string) { 88 | var u, hostname, port string 89 | hs := strings.SplitN(connect, "@", 2) 90 | hostname = hs[0] 91 | if len(hs) == 2 { 92 | u = hs[0] 93 | hostname = hs[1] 94 | } 95 | hss := strings.SplitN(hostname, ":", 2) 96 | hostname = hss[0] 97 | if len(hss) == 2 { 98 | if _, err := strconv.Atoi(hss[1]); err == nil { 99 | port = hss[1] 100 | } 101 | } 102 | return u, hostname, port 103 | } 104 | -------------------------------------------------------------------------------- /utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestArgumentsCheck(t *testing.T) { 9 | type args struct { 10 | argCount int 11 | min int 12 | max int 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | wantErr bool 18 | }{ 19 | { 20 | args: args{argCount: 1, min: 1, max: 2}, 21 | wantErr: false, 22 | }, 23 | { 24 | args: args{argCount: 2, min: 1, max: 2}, 25 | wantErr: false, 26 | }, 27 | { 28 | args: args{argCount: 3, min: 1, max: 2}, 29 | wantErr: true, 30 | }, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | if err := ArgumentsCheck(tt.args.argCount, tt.args.min, tt.args.max); (err != nil) != tt.wantErr { 35 | t.Errorf("ArgumentsCheck() error = %v, wantErr %v", err, tt.wantErr) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestQuery(t *testing.T) { 42 | type args struct { 43 | values []string 44 | keys []string 45 | ignoreCase bool 46 | } 47 | tests := []struct { 48 | name string 49 | args args 50 | want bool 51 | }{ 52 | { 53 | name: "1", 54 | args: args{ 55 | values: []string{"test1", "test2", "test3"}, 56 | keys: []string{"xxx", "test"}, 57 | }, 58 | want: true, 59 | }, 60 | { 61 | name: "2", 62 | args: args{ 63 | values: []string{"Test1", "Test2", "Test3"}, 64 | keys: []string{"test"}, 65 | }, 66 | want: false, 67 | }, 68 | { 69 | name: "3", 70 | args: args{ 71 | values: []string{"Test1", "Test2", "Test3"}, 72 | keys: []string{"xxx", "test"}, 73 | ignoreCase: true, 74 | }, 75 | want: true, 76 | }, 77 | } 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | if got := Query(tt.args.values, tt.args.keys, tt.args.ignoreCase); got != tt.want { 81 | t.Errorf("Query() = %v, want %v", got, tt.want) 82 | } 83 | }) 84 | } 85 | } 86 | 87 | func TestSortKeys(t *testing.T) { 88 | type args struct { 89 | m map[string]string 90 | } 91 | tests := []struct { 92 | name string 93 | args args 94 | want []string 95 | }{ 96 | { 97 | args: args{ 98 | m: map[string]string{ 99 | "ac": "", "ab": "", 100 | "cc": "", "cd": "", 101 | "bb": "", "ba": "", 102 | }, 103 | }, 104 | want: []string{"ab", "ac", "ba", "bb", "cc", "cd"}, 105 | }, 106 | } 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | if got := SortKeys(tt.args.m); !reflect.DeepEqual(got, tt.want) { 110 | t.Errorf("SortKeys() = %v, want %v", got, tt.want) 111 | } 112 | }) 113 | } 114 | } 115 | 116 | func TestParseConnect(t *testing.T) { 117 | type args struct { 118 | connect string 119 | } 120 | tests := []struct { 121 | name string 122 | args args 123 | want string 124 | want1 string 125 | want2 string 126 | }{ 127 | { 128 | args: args{ 129 | connect: "root@1.2.3.4:22022", 130 | }, 131 | want: "root", 132 | want1: "1.2.3.4", 133 | want2: "22022", 134 | }, 135 | { 136 | args: args{ 137 | connect: "root@1.2.3.4", 138 | }, 139 | want: "root", 140 | want1: "1.2.3.4", 141 | want2: "", 142 | }, 143 | { 144 | args: args{ 145 | connect: "1.2.3.4", 146 | }, 147 | want: "", 148 | want1: "1.2.3.4", 149 | want2: "", 150 | }, 151 | { 152 | args: args{ 153 | connect: "root@1.2.3.4", 154 | }, 155 | want: "root", 156 | want1: "1.2.3.4", 157 | want2: "", 158 | }, 159 | } 160 | for _, tt := range tests { 161 | t.Run(tt.name, func(t *testing.T) { 162 | got, got1, got2 := ParseConnect(tt.args.connect) 163 | if got != tt.want { 164 | t.Errorf("ParseConnect() got = %v, want %v", got, tt.want) 165 | } 166 | if got1 != tt.want1 { 167 | t.Errorf("ParseConnect() got1 = %v, want %v", got1, tt.want1) 168 | } 169 | if got2 != tt.want2 { 170 | t.Errorf("ParseConnect() got2 = %v, want %v", got2, tt.want2) 171 | } 172 | }) 173 | } 174 | } 175 | --------------------------------------------------------------------------------