├── .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 | [](https://github.com/xwjdsh/manssh/releases/latest)
4 | [](https://travis-ci.org/xwjdsh/manssh)
5 | [](https://goreportcard.com/report/github.com/xwjdsh/manssh)
6 | [](https://gocover.io/github.com/xwjdsh/manssh)
7 | [](https://godoc.org/github.com/xwjdsh/manssh)
8 | [](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 | 
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 |
--------------------------------------------------------------------------------