├── docs ├── sshabu-quick.gif └── license.tpl ├── .gitignore ├── scripts └── completion.sh ├── main.go ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── release.yml │ └── tests.yml ├── .gitlab-ci.yml ├── go.mod ├── pkg ├── sshabu_example.yaml ├── template.go ├── checker.go ├── compare │ ├── compare_test.go │ └── compare.go ├── template_test.go ├── checker_test.go ├── openssh_conf.gtpl ├── types_test.go └── types.go ├── cmd ├── init.go ├── edit.go ├── connect.go ├── root.go └── apply.go ├── .goreleaser.yaml ├── README.md ├── LICENSE └── go.sum /docs/sshabu-quick.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ratsky-oss/sshabu/HEAD/docs/sshabu-quick.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | openssh.* 2 | sshabu.yaml 3 | *.drowio 4 | *.bkp 5 | dist/ 6 | .DS_Store 7 | completions 8 | .vscode/ 9 | sshabu 10 | -------------------------------------------------------------------------------- /docs/license.tpl: -------------------------------------------------------------------------------- 1 | {{ range . }} 2 | ## {{ .Name }} 3 | 4 | * Name: {{ .Name }} 5 | * Version: {{ .Version }} 6 | * License: [{{ .LicenseName }}]({{ .LicenseURL }}) 7 | 8 | ``` 9 | {{ .LicenseText }} 10 | ``` 11 | {{ end }} 12 | -------------------------------------------------------------------------------- /scripts/completion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # scripts/completions.sh 3 | set -e 4 | rm -rf completions 5 | mkdir completions 6 | # TODO: replace your-cli with your binary name 7 | for sh in bash zsh fish; do 8 | go run main.go completion "$sh" >"completions/sshabu.$sh" 9 | done -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2023 alvtsky github.com/Ra-sky 3 | */ 4 | package main 5 | 6 | import ( 7 | "sshabu/cmd" 8 | ) 9 | 10 | var ( 11 | version = "dev" 12 | commit = "none" 13 | date = "unknown" 14 | ) 15 | 16 | func main() { 17 | cmd.SetVersionInfo(version, commit, date) 18 | cmd.Execute() 19 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'FeatureRequest: Your Name' 5 | labels: '' 6 | assignees: alvtsky 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | .build: 2 | image: golang:1.21-alpine3.17 3 | tags: 4 | - docker 5 | - walnut 6 | variables: 7 | GO_PROJECT: sshabu 8 | 9 | stages: 10 | - building 11 | 12 | 13 | build: 14 | stage: building 15 | extends: .build 16 | script: 17 | - GOOS=linux GOARCH=amd64 go build -o $GO_PROJECT-linux-amd64 18 | # - GOOS=linux GOARCH=386 go build -o $GO_PROJECT-linux-386 19 | # - GOOS=windows GOARCH=amd64 go build -o $GO_PROJECT-windows-amd64 20 | # - GOOS=windows GOARCH=386 go build -o $GO_PROJECT-windows-386 21 | - GOOS=darwin GOARCH=arm64 go build -o $GO_PROJECT-darwin-arm64 22 | artifacts: 23 | paths: 24 | - $GO_PROJECT-linux-amd64 25 | - $GO_PROJECT-darwin-arm64 26 | rules: 27 | - if: $CI_COMMIT_REF_NAME == "main" 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | permissions: 10 | contents: write 11 | packages: write 12 | # issues: write 13 | 14 | jobs: 15 | goreleaser: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | - run: git fetch --force --tags 22 | - uses: actions/setup-go@v4 23 | with: 24 | go-version: stable 25 | # - run: chmod +x ./scripts/completion.sh 26 | - uses: goreleaser/goreleaser-action@v5 27 | with: 28 | distribution: goreleaser 29 | version: latest 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 33 | # TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'Bug: Name' 5 | labels: '' 6 | assignees: alvtsky 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module sshabu 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/spf13/cobra v1.7.0 7 | github.com/spf13/viper v1.16.0 8 | ) 9 | 10 | require ( 11 | github.com/Masterminds/goutils v1.1.1 // indirect 12 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 13 | github.com/google/uuid v1.1.2 // indirect 14 | github.com/huandu/xstrings v1.3.3 // indirect 15 | github.com/imdario/mergo v0.3.11 // indirect 16 | github.com/mitchellh/copystructure v1.0.0 // indirect 17 | github.com/mitchellh/reflectwalk v1.0.0 // indirect 18 | github.com/shopspring/decimal v1.2.0 // indirect 19 | golang.org/x/crypto v0.9.0 // indirect 20 | ) 21 | 22 | require ( 23 | github.com/Masterminds/sprig/v3 v3.2.3 24 | github.com/fsnotify/fsnotify v1.6.0 // indirect 25 | github.com/hashicorp/hcl v1.0.0 // indirect 26 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 27 | github.com/magiconair/properties v1.8.7 // indirect 28 | github.com/mitchellh/mapstructure v1.5.0 // indirect 29 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 30 | github.com/spf13/afero v1.9.5 // indirect 31 | github.com/spf13/cast v1.5.1 // indirect 32 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 33 | github.com/spf13/pflag v1.0.5 // indirect 34 | github.com/subosito/gotenv v1.4.2 // indirect 35 | golang.org/x/sys v0.8.0 // indirect 36 | golang.org/x/text v0.9.0 // indirect 37 | gopkg.in/ini.v1 v1.67.0 // indirect 38 | gopkg.in/yaml.v3 v3.0.1 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /pkg/sshabu_example.yaml: -------------------------------------------------------------------------------- 1 | # # ---------------------------------------------------------------------- 2 | # # Default options for all hosts 3 | # GlobalOptions: 4 | # LogLevel: INFO 5 | # # ---------------------------------------------------------------------- 6 | # # Top level standalone host list 7 | 8 | # Hosts: 9 | # - Name: smth_ungrouped # Host example 10 | # HostName: host.example.com # Key: value ssh_config(5) 11 | # User: user2 12 | # IdentityFile: /path/to/key2 13 | # Port: 2223 14 | 15 | ## ---------------------------------------------------------------------- 16 | ## Top level group list 17 | Groups: 18 | - Name: work # Some top level group 19 | Options: # ssh_config(5) 20 | User: user 21 | IdentityFile: ~/.ssh/id_rsa_work 22 | Subgroups: # List of subgroups that will inherit "work" Options 23 | - Name: project1 24 | Options: 25 | IdentityFile: ~/.ssh/id_rsa_work_p1 26 | Hosts: 27 | - Name: project1-test 28 | HostName: 192.168.1.2 29 | Port: 2222 30 | - Name: project2 31 | Hosts: 32 | - Name: project2-dev 33 | HostName: 192.168.11.3 34 | 35 | - Name: home # Another top group 36 | Hosts: 37 | - Name: home-gitlab 38 | HostName: gitlab.ratsky.local -------------------------------------------------------------------------------- /pkg/template.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package sshabu 16 | 17 | import ( 18 | "bytes" 19 | _ "embed" 20 | 21 | "io" 22 | "text/template" 23 | 24 | "github.com/Masterminds/sprig/v3" 25 | ) 26 | 27 | //go:embed sshabu_example.yaml 28 | var sshabu_example string 29 | //go:embed *.gtpl 30 | var ssh_template string 31 | 32 | func ConfigExample() string{ 33 | return sshabu_example 34 | } 35 | 36 | func RenderTemplate(vars interface{}, out io.Writer) error { 37 | t := template.New("openssh_config") 38 | 39 | var funcMap template.FuncMap = map[string]interface{}{} 40 | // copied from: https://github.com/helm/helm/blob/8648ccf5d35d682dcd5f7a9c2082f0aaf071e817/pkg/engine/engine.go#L147-L154 41 | funcMap["include"] = func(name string, data interface{}) (string, error) { 42 | buf := bytes.NewBuffer(nil) 43 | if err := t.ExecuteTemplate(buf, name, data); err != nil { 44 | return "", err 45 | } 46 | return buf.String(), nil 47 | } 48 | 49 | t, err := t.Funcs(sprig.TxtFuncMap()).Funcs(funcMap).Parse(ssh_template) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | err = t.Execute(out, &vars) 55 | if err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Go Tests with Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 # Important for some Go tools that need full history 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@v4 16 | with: 17 | go-version: '1.21' # Or your preferred version 18 | check-latest: true # Ensures you get the latest patch version 19 | 20 | - name: Cache Go modules 21 | uses: actions/cache@v3 22 | with: 23 | path: | 24 | ~/go/pkg/mod 25 | ${GITHUB_WORKSPACE}/.go-cache 26 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 27 | restore-keys: | 28 | ${{ runner.os }}-go- 29 | 30 | - name: Run tests (recursive) 31 | run: | 32 | # Print Go version for debugging 33 | go version 34 | 35 | # Verify we're finding all test files 36 | echo "Test files found:" 37 | find . -name "*_test.go" | sed 's/^/ /' 38 | 39 | # Run tests with coverage (./... means recursive) 40 | go test -v -coverprofile=coverage.out -covermode=atomic ./... 41 | 42 | # Generate coverage report 43 | go tool cover -func=coverage.out 44 | go tool cover -html=coverage.out -o coverage.html 45 | 46 | - name: Upload coverage report 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: go-coverage-report 50 | path: | 51 | coverage.out 52 | coverage.html 53 | 54 | # # Optional: Upload to codecov.io 55 | # - name: Upload to Codecov 56 | # if: success() # Only run if tests passed 57 | # uses: codecov/codecov-action@v3 58 | # with: 59 | # token: ${{ secrets.CODECOV_TOKEN }} # Only needed for private repos 60 | # file: coverage.out 61 | # flags: unittests 62 | # name: go-coverage 63 | # fail_ci_if_error: false -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | // "fmt" 19 | // "log" 20 | _ "embed" 21 | "fmt" 22 | "os" 23 | sshabu "sshabu/pkg" 24 | 25 | "github.com/spf13/cobra" 26 | ) 27 | 28 | // initCmd represents the init command 29 | var initCmd = &cobra.Command{ 30 | PersistentPreRun: func(cmd *cobra.Command, args []string){ 31 | 32 | }, 33 | Use: "init", 34 | Short: "Create default directories", 35 | Long: `Init command search for $HOME/.sshabu/ directory. 36 | If no directory found, init will create it and create default $HOME/.sshabu/sshabu.yaml config.`, 37 | Run: func(cmd *cobra.Command, args []string) { 38 | home, err := os.UserHomeDir() 39 | cobra.CheckErr(err) 40 | if _, err := os.Stat(home+"/.sshabu/"); os.IsNotExist(err) { 41 | fmt.Println("Creating base paths") 42 | err = os.MkdirAll(home+"/.sshabu/", 0750) 43 | cobra.CheckErr(err) 44 | err = os.WriteFile(home+"/.sshabu/sshabu.yaml", []byte(sshabu.ConfigExample()), 0660) 45 | fmt.Println("Success ʕ♥ᴥ♥ʔ") 46 | cobra.CheckErr(err) 47 | } else { 48 | fmt.Println("Base sshabu path already exists") 49 | fmt.Println("Doing nothing ಠ_ಠ") 50 | } 51 | 52 | }, 53 | } 54 | 55 | func init() { 56 | rootCmd.AddCommand(initCmd) 57 | 58 | // Here you will define your flags and configuration settings. 59 | 60 | // Cobra supports Persistent Flags which will work for this command 61 | // and all subcommands, e.g.: 62 | // initCmd.PersistentFlags().String("foo", "", "A help for foo") 63 | 64 | // Cobra supports local flags which will only run when this command 65 | // is called directly, e.g.: 66 | // initCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 67 | } 68 | -------------------------------------------------------------------------------- /pkg/checker.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package sshabu 16 | 17 | import ( 18 | "bufio" 19 | "fmt" 20 | "io" 21 | "os" 22 | "os/exec" 23 | "strings" 24 | ) 25 | 26 | func OpensshCheck(openssh_cfg string) error { 27 | fmt.Println("Verifing result...") 28 | vcmd := exec.Command("bash","-c","ssh -GTF " + openssh_cfg + " test") 29 | vcmd.Stderr = os.Stderr 30 | vcmd.Stdin = nil 31 | if err := vcmd.Run(); err != nil{ 32 | return err 33 | } 34 | fmt.Println("Seems legit to me") 35 | return nil 36 | } 37 | 38 | func DestinationHosts(r io.Reader) ([]string, error) { 39 | scanner := bufio.NewScanner(r) 40 | 41 | // Slice to store values after "Host " 42 | hostValues := []string{} 43 | 44 | for scanner.Scan() { 45 | line := scanner.Text() 46 | 47 | // Check if the line starts with "Host " and doesn't contain "*" or "!" 48 | if strings.HasPrefix(line, "Host ") && !strings.Contains(line, "*") && !strings.Contains(line, "!") { 49 | hostValue := strings.TrimPrefix(line, "Host ") 50 | 51 | // Split hostValue by spaces and add the resulting entities to hostValues 52 | entities := strings.Fields(hostValue) 53 | hostValues = append(hostValues, entities...) 54 | } 55 | } 56 | 57 | if err := scanner.Err(); err != nil { 58 | return nil, err 59 | } 60 | 61 | return hostValues, nil 62 | } 63 | 64 | func AskForConfirmation() bool { 65 | var response string 66 | _, err := fmt.Scanln(&response) 67 | if err != nil { 68 | fmt.Println("Please enter 'yes' or 'no'.") 69 | return false 70 | } 71 | response = strings.ToLower(response) 72 | switch response { 73 | case "yes", "y": 74 | return true 75 | case "no", "n": 76 | return false 77 | default: 78 | fmt.Println("Please enter 'yes' or 'no'.") 79 | return false 80 | } 81 | } -------------------------------------------------------------------------------- /cmd/edit.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package cmd 15 | 16 | import ( 17 | "bufio" 18 | "fmt" 19 | "os" 20 | "os/exec" 21 | "strings" 22 | 23 | "github.com/spf13/cobra" 24 | ) 25 | 26 | var editCmd = &cobra.Command{ 27 | Use: "edit", 28 | Short: "Edit sshabu config file", 29 | Long: `Edit the sshabu configuration file with editor. 30 | If no editor command found, ask you to choose between vim and nano. 31 | 32 | After editing you will be promted if you'd like to use 'sshabu apply'`, 33 | Run: func(cmd *cobra.Command, args []string) { 34 | editFile(cfgFile) 35 | }, 36 | } 37 | 38 | func editFile(filePath string) { 39 | cmd := exec.Command("editor", filePath) 40 | cmd.Stdin = os.Stdin 41 | cmd.Stdout = os.Stdout 42 | cmd.Stderr = os.Stderr 43 | err := cmd.Run() 44 | if err != nil { 45 | 46 | fmt.Println("⸫ Using config file:", cfgFile) 47 | 48 | editor := "" 49 | fmt.Println("Editor is not installed.") 50 | fmt.Println("Choose an editor [nano/vim or press Enter]: ") 51 | reader := bufio.NewReader(os.Stdin) 52 | choice, _ := reader.ReadString('\n') 53 | choice = strings.TrimSpace(choice) 54 | switch choice { 55 | case "nano": 56 | editor = "nano" 57 | case "vim": 58 | editor = "vim" 59 | default: 60 | fmt.Println("Vim is the right choice!") 61 | editor = "vim" 62 | } 63 | cmd := exec.Command(editor, filePath) 64 | cmd.Stdin = os.Stdin 65 | cmd.Stdout = os.Stdout 66 | cmd.Stderr = os.Stderr 67 | err := cmd.Run() 68 | if err != nil { 69 | fmt.Printf("Failed to open editor: %v\n", err) 70 | return 71 | } 72 | } 73 | 74 | 75 | reader := bufio.NewReader(os.Stdin) 76 | fmt.Print("Would you like sshabu to apply changes? [y/n]: ") 77 | text, _ := reader.ReadString('\n') 78 | text = strings.TrimSpace(text) 79 | 80 | 81 | if strings.ToLower(text) == "y" { 82 | if err := RunApply([]string{}); err != nil { 83 | cobra.CheckErr(err) 84 | } 85 | } else { 86 | fmt.Println("Ok.(╥﹏╥)") 87 | fmt.Println("Changes was not applied.") 88 | } 89 | } 90 | 91 | 92 | func init() { 93 | rootCmd.AddCommand(editCmd) 94 | } 95 | -------------------------------------------------------------------------------- /pkg/compare/compare_test.go: -------------------------------------------------------------------------------- 1 | package compare 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "fmt" 7 | ) 8 | 9 | func Test_transformDifferencesToReadableFormat(t *testing.T) { 10 | type args struct { 11 | differences []Difference 12 | firstBites Bites 13 | secondBites Bites 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | want []string 19 | }{ 20 | { 21 | name: "No differences", 22 | args: args{ 23 | differences: []Difference{}, 24 | firstBites: Bites{Content: []string{"A"}}, 25 | secondBites: Bites{Content: []string{"A"}}, 26 | }, 27 | want: []string{ fmt.Sprintf("%d: %s%s%s", 1, White, "A", White)}, 28 | }, 29 | { 30 | name: "Addition in the second file", 31 | args: args{ 32 | differences: []Difference{{lineNumber: 2, line: "B", Added: true}}, 33 | firstBites: Bites{Content: []string{"A"}}, 34 | secondBites: Bites{Content: []string{"A","B"}}, 35 | }, 36 | want: []string{ 37 | fmt.Sprintf("%d: %s%s%s", 1, White, "A", White), 38 | fmt.Sprintf("%d: %s%s%s", 2, Green, "B", White), 39 | }, 40 | }, 41 | } 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | if got := TransformDifferencesToReadableFormat(tt.args.differences, tt.args.firstBites, tt.args.secondBites); !reflect.DeepEqual(got, tt.want) { 45 | t.Errorf("transformDifferencesToReadableFormat() = %v, want %v", got, tt.want) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | func Test_diffBites(t *testing.T) { 52 | type args struct { 53 | bites1 Bites 54 | bites2 Bites 55 | } 56 | tests := []struct { 57 | name string 58 | args args 59 | want []Difference 60 | }{ 61 | { 62 | name: "Add one line", 63 | args: args{ 64 | bites1: Bites{Content: []string{"A","B", "C"}}, 65 | bites2: Bites{Content: []string{"A","B", "C", "D"}}, 66 | }, 67 | want: []Difference{{lineNumber: 4, line: "D", Added: true}}, 68 | }, 69 | { 70 | name: "Del one line", 71 | args: args{ 72 | bites1: Bites{Content: []string{"A","B", "C"}}, 73 | bites2: Bites{Content: []string{"A","B"}}, 74 | }, 75 | want: []Difference{{lineNumber: 3, line: "C", Added: false}}, 76 | }, 77 | { 78 | name: "Multy edit", 79 | args: args{ 80 | bites1: Bites{Content: []string{"B","E","D","E"}}, 81 | bites2: Bites{Content: []string{"A","B","D","E","D","E","C"}}, 82 | }, 83 | want: []Difference{ {lineNumber: 1, line: "A", Added: true}, 84 | {lineNumber: 3, line: "D", Added: true}, 85 | {lineNumber: 7, line: "C", Added: true}, 86 | }, 87 | }, 88 | } 89 | for _, tt := range tests { 90 | t.Run(tt.name, func(t *testing.T) { 91 | if got := DiffBites(tt.args.bites1, tt.args.bites2); !reflect.DeepEqual(got, tt.want) { 92 | t.Errorf("diffBites() = %v, want %v", got, tt.want) 93 | } 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /cmd/connect.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package cmd 15 | 16 | import ( 17 | "fmt" 18 | "os" 19 | "os/exec" 20 | sshabu "sshabu/pkg" 21 | 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // connectCmd represents the connect command 26 | var connectCmd = &cobra.Command{ 27 | Use: "connect [flags] [user@]name_of_host", 28 | Short: "Just a wrapper around ssh command", 29 | Long: `Generally just a wrapper around ssh command with autocompletion from sshabu config. 30 | 31 | Base usage: 32 | ~ sshabu connect some_host 33 | # Command above wll be transformed to the following 34 | # ssh -F $HOME/.sshabu/openssh.config some_host 35 | 36 | Optionally you could pass openssh parametrs or override user 37 | ~ sshabu connect -o "-p 2222 -i /path/to/dir" user@host_example 38 | # ssh -F $HOME/.sshabu/openssh.config -p 2222 -i /path/to/dir user@host_example 39 | `, 40 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 41 | if len(args) != 0 { 42 | return nil, cobra.ShellCompDirectiveNoFileComp 43 | } 44 | 45 | file, _ := os.Open(opensshDestconfigFile) 46 | 47 | defer file.Close() 48 | 49 | hostValues, err := sshabu.DestinationHosts(file) 50 | if err != nil { 51 | file.Close() 52 | return nil, cobra.ShellCompDirectiveNoFileComp 53 | } 54 | return hostValues, cobra.ShellCompDirectiveNoFileComp 55 | }, 56 | Run: func(cmd *cobra.Command, args []string) { 57 | // Construct the ssh command with -I option 58 | 59 | args = append(args, extraOptions) 60 | 61 | sshArgs := append([]string{"-F", opensshDestconfigFile}, args...) 62 | 63 | fmt.Println("Running SSH command:", "ssh", sshArgs) 64 | 65 | // Execute the SSH command 66 | scmd := exec.Command("ssh", sshArgs...) 67 | scmd.Stdout = os.Stdout 68 | scmd.Stderr = os.Stderr 69 | scmd.Stdin = os.Stdin 70 | if err := scmd.Run(); err != nil { 71 | fmt.Println("Error executing SSH command:", err) 72 | os.Exit(1) 73 | } 74 | }, 75 | } 76 | 77 | var extraOptions string 78 | 79 | func init() { 80 | connectCmd.Flags().StringVarP(&extraOptions, "options", "o", "", "openssh options passed to ssh command") 81 | rootCmd.AddCommand(connectCmd) 82 | 83 | // Here you will define your flags and configuration settings. 84 | 85 | // Cobra supports Persistent Flags which will work for this command 86 | // and all subcommands, e.g.: 87 | // connectCmd.PersistentFlags().String("foo", "", "A help for foo") 88 | 89 | // Cobra supports local flags which will only run when this command 90 | // is called directly, e.g.: 91 | // connectCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 92 | } 93 | -------------------------------------------------------------------------------- /pkg/template_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package sshabu 16 | 17 | // Import necessary packages for testing 18 | import ( 19 | "bytes" 20 | "os" 21 | "path/filepath" 22 | // "strings" 23 | "testing" 24 | 25 | "github.com/spf13/viper" 26 | ) 27 | func readExpectedOutput(t *testing.T, filePath string) string { 28 | content, err := os.ReadFile(filePath) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | return string(content) 33 | } 34 | func TestConfigExample(t *testing.T) { 35 | tests := []struct { 36 | name string 37 | want string 38 | }{ 39 | { 40 | name: "Example Config Test", 41 | want: readExpectedOutput(t, "sshabu_example.yaml"), 42 | }, 43 | // Add more test cases if needed 44 | } 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | if got := ConfigExample(); got != tt.want { 48 | t.Errorf("ConfigExample() = %v, want %v", got, tt.want) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestRenderTemplate(t *testing.T) { 55 | // Get the current directory 56 | currentDir, err := os.Getwd() 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | // Create the path to sshabu_example.yaml 62 | yamlFilePath := filepath.Join(currentDir, "sshabu_example.yaml") 63 | 64 | // Set up Viper with the YAML file 65 | viper.SetConfigFile(yamlFilePath) 66 | err = viper.ReadInConfig() 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | // Unmarshal YAML into Shabu 72 | var shabu Shabu 73 | err = viper.UnmarshalExact(&shabu) 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | 78 | // Call Boil 79 | err = shabu.Boil() 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | // Create a buffer for RenderTemplate 85 | buf := new(bytes.Buffer) 86 | 87 | // Render the template 88 | err = RenderTemplate(shabu, buf) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | 93 | // Optionally, you can add assertions based on the expected output of RenderTemplate 94 | 95 | // Example assertion: 96 | expectedOutput := `# ----------------------- 97 | # RATSKY SSHABU 98 | 99 | 100 | Host project1-test 101 | Hostname 192.168.1.2 102 | IdentityFile ~/.ssh/id_rsa_work_p1 103 | Port 2222 104 | User user 105 | 106 | Host project2-dev 107 | Hostname 192.168.11.3 108 | IdentityFile ~/.ssh/id_rsa_work 109 | User user 110 | 111 | Host home-gitlab 112 | Hostname gitlab.ratsky.local 113 | ` 114 | if gotOutput := buf.String(); gotOutput != expectedOutput { 115 | t.Errorf("Rendered output does not match. \nGot: \n%s|\n Want: \n%s|", gotOutput, expectedOutput) 116 | } 117 | } -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "path/filepath" 21 | 22 | "github.com/spf13/cobra" 23 | "github.com/spf13/viper" 24 | // "sshabu/pkg" 25 | ) 26 | 27 | var cfgFile string 28 | var opensshTmpFile string 29 | var opensshDestconfigFile string 30 | 31 | // rootCmd represents the base command when called without any subcommands 32 | var rootCmd = &cobra.Command{ 33 | PersistentPreRun: func(cmd *cobra.Command, args []string){ 34 | if cmd.Name() == "completion"{ 35 | return 36 | } 37 | initConfig() 38 | }, 39 | Use: "sshabu", 40 | Version: "0.0.1-alpha", 41 | Short: "Is a robust SSH client management tool", 42 | Long: `is a robust SSH client management tool designed to streamline the process of connecting to multiple servers effortlessly. 43 | This tool leverages OpenSSH and offers a user-friendly interface to enhance the overall SSH experience. 44 | With Sshabu, managing SSH configurations becomes more intuitive, allowing users to organize and connect to their servers efficiently. 45 | 46 | Sshabu works with sshabu.yaml and openssh.config file. 47 | openssh.config will be created next to sshabu.yaml 48 | 49 | sshabu.yaml location - $HOME (user's home dir) 50 | `, 51 | // Uncomment the following line if your bare application 52 | // has an action associated with it: 53 | // Run: func(cmd *cobra.Command, args []string) { }, 54 | } 55 | 56 | 57 | // Execute adds all child commands to the root command and sets flags appropriately. 58 | // This is called by main.main(). It only needs to happen once to the rootCmd. 59 | func Execute() { 60 | err := rootCmd.Execute() 61 | if err != nil { 62 | os.Exit(1) 63 | } 64 | } 65 | 66 | func init() { 67 | // cobra.OnInitialize(initConfig) 68 | 69 | // Here you will define your flags and configuration settings. 70 | // Cobra supports persistent flags, which, if defined here, 71 | // will be global for your application. 72 | 73 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "manully override config file path") 74 | } 75 | 76 | // initConfig reads in config file and ENV variables if set. 77 | func initConfig() { 78 | if cfgFile != "" { 79 | viper.SetConfigFile(cfgFile) 80 | } else { 81 | home, err := os.UserHomeDir() 82 | cobra.CheckErr(err) 83 | 84 | viper.SetConfigType("yaml") 85 | viper.SetConfigName("sshabu") 86 | // viper.AddConfigPath("$PWD") 87 | viper.AddConfigPath(home+"/.sshabu") 88 | } 89 | 90 | if err := viper.ReadInConfig(); err == nil { 91 | cfgFile = viper.ConfigFileUsed() 92 | cfgPath := filepath.Dir(cfgFile) 93 | opensshTmpFile = cfgPath+"/openssh.tmp" 94 | opensshDestconfigFile = cfgPath+"/openssh.config" 95 | 96 | os.OpenFile(opensshTmpFile, os.O_RDONLY|os.O_CREATE, 0666) 97 | os.OpenFile(opensshDestconfigFile, os.O_RDONLY|os.O_CREATE, 0666) 98 | } else { 99 | fmt.Println("(╯°□°)╯︵ ɹoɹɹƎ") 100 | cfgFile, _ = filepath.Abs(viper.ConfigFileUsed()) 101 | fmt.Printf("\n%s\n└─ %s",cfgFile,err) 102 | os.Exit(1) 103 | } 104 | } 105 | 106 | 107 | func SetVersionInfo(version, commit, date string) { 108 | rootCmd.Version = fmt.Sprintf("%s \nBuilt on %s from Git SHA %s)", version, date, commit) 109 | } -------------------------------------------------------------------------------- /cmd/apply.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package cmd 15 | 16 | import ( 17 | "bytes" 18 | "fmt" 19 | "os" 20 | "sshabu/pkg" 21 | "sshabu/pkg/compare" 22 | "strings" 23 | 24 | "github.com/spf13/cobra" 25 | "github.com/spf13/viper" 26 | ) 27 | 28 | func RunApply(args []string) error { 29 | // Create a fresh command instance 30 | runApplyCmd := &cobra.Command{ 31 | Use: applyCmd.Use, 32 | Run: applyCmd.Run, 33 | } 34 | // Copy all flags 35 | runApplyCmd.Flags().AddFlagSet(applyCmd.Flags()) 36 | runApplyCmd.SetArgs(args) 37 | if err := viper.ReadInConfig(); err != nil{ 38 | return err 39 | } 40 | 41 | err := runApplyCmd.Execute() 42 | return err 43 | } 44 | // applyCmd represents the apply command 45 | var applyCmd = &cobra.Command{ 46 | Use: "apply", 47 | Short: "Transform sshabu.yaml to openssh.config", 48 | Long: `Apply the generate openssh_config according to yaml specification. 49 | Command is going to ask you confirmation before overriding destination openssh.config. 50 | openssh.config file is located right next to the used sshabu.yaml`, 51 | Run: func(cmd *cobra.Command, args []string) { 52 | 53 | fmt.Println("⸫ Using config file:", cfgFile) 54 | 55 | var shabu sshabu.Shabu 56 | err := viper.UnmarshalExact(&shabu) 57 | cobra.CheckErr(err) 58 | // if shabu.AreAllUnique(){ 59 | // fmt.Println("YAML seems OK") 60 | // } else { 61 | // fmt.Println("Error: 'Name' Fields must be unique") 62 | // os.Exit(1) 63 | // } 64 | // names := sshabu.FindNamesInShabu(shabu) 65 | 66 | err = shabu.Boil() 67 | cobra.CheckErr(err) 68 | 69 | buf := new(bytes.Buffer) 70 | err = sshabu.RenderTemplate(shabu, buf) 71 | cobra.CheckErr(err) 72 | 73 | err = os.WriteFile(opensshTmpFile, buf.Bytes(), 0600) 74 | cobra.CheckErr(err) 75 | sshabu.OpensshCheck(opensshTmpFile) 76 | 77 | var ( 78 | destFile compare.Bites 79 | tmpFile compare.Bites 80 | ) 81 | 82 | destFile.TakeBites(opensshDestconfigFile) 83 | tmpFile.TakeBites(opensshTmpFile) 84 | 85 | differences := compare.DiffBites(destFile, tmpFile) 86 | 87 | if len(differences) == 0{ 88 | fmt.Println("---------------------") 89 | fmt.Println("No changes! ʕっ•ᴥ•ʔっ") 90 | fmt.Println("---------------------") 91 | return 92 | } 93 | 94 | 95 | 96 | if !forceApply { 97 | 98 | resultStrings := compare.TransformDifferencesToReadableFormat(differences, destFile, tmpFile) 99 | 100 | for _,line := range(resultStrings) { 101 | fmt.Println(line) 102 | } 103 | 104 | fmt.Println("\nDo you really want to apply changes? (yes/no): ") 105 | if !sshabu.AskForConfirmation() { 106 | fmt.Println("Aborted") 107 | return 108 | } 109 | } 110 | 111 | err = os.WriteFile(opensshDestconfigFile, []byte(strings.Join(tmpFile.Content, "\n")), 0644) 112 | os.Remove(opensshTmpFile) 113 | if err != nil { 114 | fmt.Println("Error overwriting the file:", err) 115 | return 116 | } 117 | fmt.Println("Yep-Yep-Yep! It's time for shabu! ʕ •́؈•̀)") 118 | }, 119 | } 120 | 121 | var forceApply bool 122 | 123 | func init() { 124 | applyCmd.Flags().BoolVarP(&forceApply, "force", "f", false, "Apply configuration without confirmation") 125 | rootCmd.AddCommand(applyCmd) 126 | } -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 1 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | # - ./scripts/completion.sh 19 | 20 | builds: 21 | - env: 22 | - CGO_ENABLED=0 23 | goos: 24 | - linux 25 | # - windows 26 | - darwin 27 | 28 | archives: 29 | - format: tar.gz 30 | # this name template makes the OS and Arch compatible with the results of `uname`. 31 | name_template: >- 32 | {{ .ProjectName }}_ 33 | {{- title .Os }}_ 34 | {{- if eq .Arch "amd64" }}x86_64 35 | {{- else if eq .Arch "386" }}i386 36 | {{- else }}{{ .Arch }}{{ end }} 37 | {{- if .Arm }}v{{ .Arm }}{{ end }} 38 | # use zip for windows archives 39 | format_overrides: 40 | - goos: windows 41 | format: zip 42 | 43 | changelog: 44 | use: git 45 | sort: asc 46 | abbrev: -1 47 | 48 | filters: 49 | exclude: 50 | - "^docs:" 51 | - "^test:" 52 | include: 53 | - "^feat:" 54 | - "^bug:" 55 | brews: 56 | - 57 | # NOTE: make sure the url_template, the token and given repo (github or 58 | # gitlab) owner and name are from the same kind. 59 | # We will probably unify this in the next major version like it is 60 | # done with scoop. 61 | 62 | # URL which is determined by the given Token (github, gitlab or gitea). 63 | # 64 | # Default depends on the client. 65 | # Templates: allowed 66 | url_template: "https://github.com/ratsky-oss/sshabu/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 67 | 68 | # Caveats for the user of your binary. 69 | caveats: "Use `sshabu init` to create configuration example in $HOME/.sshabu/ directory" 70 | 71 | # Your app's homepage. 72 | homepage: "https://github.com/ratsky-oss/sshabu" 73 | 74 | # Your app's description. 75 | # 76 | # Templates: allowed 77 | description: "SSH client management tool" 78 | 79 | # SPDX identifier of your app's license. 80 | license: "Apache-2.0" 81 | 82 | # Setting this will prevent goreleaser to actually try to commit the updated 83 | # formula - instead, the formula file will be stored on the dist folder only, 84 | # leaving the responsibility of publishing it to the user. 85 | # If set to auto, the release will not be uploaded to the homebrew tap 86 | # in case there is an indicator for prerelease in the tag e.g. v1.0.0-rc1 87 | # 88 | # Templates: allowed 89 | skip_upload: auto 90 | 91 | # Packages your package depends on. 92 | dependencies: 93 | - name: openssh 94 | type: optional 95 | 96 | # Custom install script for brew. 97 | # 98 | # Template: allowed 99 | # Default: 'bin.install "BinaryName"' 100 | install: | 101 | bin.install "sshabu" 102 | 103 | # Additional install instructions so you don't need to override `install`. 104 | # 105 | # Template: allowed 106 | # Since: v1.20. 107 | # extra_install: | 108 | # bash_completion.install "completions/sshabu.bash" => "sshabu" 109 | # zsh_completion.install "completions/sshabu.zsh" => "_sshabu" 110 | # fish_completion.install "completions/sshabu.fish" 111 | 112 | # Repository to push the generated files to. 113 | repository: 114 | owner: ratsky-oss 115 | name: homebrew-taps 116 | # token: "{{ .Env.GITHUB_HOMEBREW_AUTH_TOKEN }}" 117 | -------------------------------------------------------------------------------- /pkg/checker_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package sshabu 16 | 17 | import ( 18 | "bytes" 19 | // "io" 20 | "os" 21 | "os/exec" 22 | "path/filepath" 23 | "reflect" 24 | "testing" 25 | 26 | "github.com/spf13/viper" 27 | ) 28 | 29 | var execCommand = exec.Command 30 | 31 | func render(t *testing.T) string { 32 | currentDir, err := os.Getwd() 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | // Create the path to sshabu_example.yaml 38 | yamlFilePath := filepath.Join(currentDir, "sshabu_example.yaml") 39 | configFilePath := filepath.Join(currentDir, "sshabu_example.config") 40 | // Set up Viper with the YAML file 41 | viper.SetConfigFile(yamlFilePath) 42 | err = viper.ReadInConfig() 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | // Unmarshal YAML into Shabu 48 | var shabu Shabu 49 | err = viper.UnmarshalExact(&shabu) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | // Call Boil 55 | err = shabu.Boil() 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | 60 | // Create a buffer for RenderTemplate 61 | buf := new(bytes.Buffer) 62 | 63 | // Render the template 64 | err = RenderTemplate(shabu, buf) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | os.WriteFile(configFilePath, buf.Bytes(), 0600) 69 | return configFilePath 70 | } 71 | 72 | // TODO: Do not use sshabu_example.yaml rendering 73 | func TestOpensshCheck(t *testing.T) { 74 | tests := []struct { 75 | name string 76 | cmd *exec.Cmd 77 | wantErr bool 78 | }{ 79 | { 80 | name: "Test with successful SSH check", 81 | cmd: exec.Command("ssh", "-GTF", "test"), 82 | // For a real SSH check, you would set cmd: exec.Command("ssh", "-GTF", "fake_config", "test"), 83 | wantErr: false, 84 | }, 85 | // Add more test cases as needed 86 | } 87 | 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | execCommand = func(name string, arg ...string) *exec.Cmd { 91 | return tt.cmd 92 | } 93 | defer func() { execCommand = exec.Command }() 94 | path := render(t) 95 | if err := OpensshCheck(path); (err != nil) != tt.wantErr { 96 | t.Errorf("OpensshCheck() error = %v, wantErr %v", err, tt.wantErr) 97 | } 98 | os.Remove(path) 99 | }) 100 | } 101 | } 102 | 103 | // TODO: Do not use sshabu_example.yaml rendering 104 | func TestDestinationHosts(t *testing.T) { 105 | // type args struct { 106 | // r io.Reader 107 | // } 108 | tests := []struct { 109 | name string 110 | // args args 111 | want []string 112 | wantErr bool 113 | }{ 114 | { 115 | name: "CRAP", 116 | want: []string{ 117 | "project1-test", 118 | "project2-dev", 119 | "home-gitlab", 120 | }, 121 | }, 122 | } 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | path := render(t) 126 | 127 | file, _ := os.Open(path) 128 | 129 | defer file.Close() 130 | 131 | 132 | got, err := DestinationHosts(file) 133 | if (err != nil) != tt.wantErr { 134 | t.Errorf("DestinationHosts() error = %v, wantErr %v", err, tt.wantErr) 135 | return 136 | } 137 | if !reflect.DeepEqual(got, tt.want) { 138 | t.Errorf("DestinationHosts() = %v, want %v", got, tt.want) 139 | } 140 | os.Remove(path) 141 | }) 142 | } 143 | } 144 | 145 | func TestAskForConfirmation(t *testing.T) { 146 | tests := []struct { 147 | name string 148 | input string 149 | want bool 150 | }{ 151 | { 152 | name: "Valid input 'yes'", 153 | input: "yes\n", 154 | want: true, 155 | }, 156 | { 157 | name: "Valid input 'no'", 158 | input: "no\n", 159 | want: false, 160 | }, 161 | { 162 | name: "Invalid input 'invalid'", 163 | input: "invalid\n", 164 | want: false, 165 | }, 166 | { 167 | name: "Invalid input 'invalid'", 168 | input: "3\n", 169 | want: false, 170 | }, 171 | } 172 | 173 | for _, tt := range tests { 174 | t.Run(tt.name, func(t *testing.T) { 175 | // Redirect stdin for testing 176 | oldStdin := os.Stdin 177 | defer func() { os.Stdin = oldStdin }() 178 | r, w, _ := os.Pipe() 179 | os.Stdin = r 180 | defer w.Close() 181 | 182 | // Write input to stdin 183 | _, _ = w.WriteString(tt.input) 184 | 185 | got := AskForConfirmation() 186 | 187 | if got != tt.want { 188 | t.Errorf("AskForConfirmation() = %v, want %v", got, tt.want) 189 | } 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /pkg/compare/compare.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package compare 15 | 16 | import ( 17 | "os" 18 | "bufio" 19 | "fmt" 20 | ) 21 | 22 | const ( 23 | Reset = "\033[0m" 24 | Red = "\033[31m" 25 | Green = "\033[32m" 26 | Yellow = "\033[33m" 27 | Blue = "\033[34m" 28 | Purple = "\033[35m" 29 | Cyan = "\033[36m" 30 | Gray = "\033[37m" 31 | White = "\033[97m" 32 | 33 | ) 34 | 35 | type Difference struct { 36 | lineNumber int 37 | line string 38 | Added bool 39 | } 40 | 41 | type Bites struct { 42 | length int 43 | Content []string 44 | } 45 | 46 | func (bites *Bites) TakeBites(path string) { 47 | var lineArray []string 48 | 49 | file, err := os.Open(path) 50 | check(err) 51 | defer file.Close() 52 | 53 | scanner := bufio.NewScanner(file) 54 | 55 | for scanner.Scan() { 56 | line := scanner.Text() 57 | lineArray = append(lineArray, line) 58 | } 59 | 60 | bites.Content = lineArray 61 | bites.length = len(lineArray) 62 | } 63 | 64 | func TransformDifferencesToReadableFormat(differences []Difference, firstBites Bites, secondBites Bites) []string { 65 | var result []string 66 | 67 | // Определение максимальной длины номера строки для выравнивания 68 | maxLineNum := max(firstBites.length, len(secondBites.Content)) 69 | maxLineNumLen := len(fmt.Sprintf("%d", maxLineNum)) 70 | 71 | // Форматирование строки с учетом выравнивания номера строки 72 | // lineFormat := fmt.Sprintf("%%%dd: %%s%%s%%s", maxLineNumLen) 73 | 74 | for index, line := range secondBites.Content { 75 | color := Reset 76 | resultStr := "" 77 | resultStr = fmt.Sprintf("%*d: %s%s%s", maxLineNumLen, index+1, color, line, Reset) 78 | for _, diff := range differences { 79 | if diff.lineNumber == index+1 { 80 | if diff.Added { 81 | color = Green 82 | resultStr = fmt.Sprintf("%*d:%s + %s%s", maxLineNumLen, index+1, color, line, Reset) 83 | } else { 84 | color = Red 85 | resultStr = fmt.Sprintf("%*d:%s - %s\n %*s%s+ %s%s", maxLineNumLen, index+1, color, diff.line ,maxLineNumLen,"",Green, line, Reset) 86 | } 87 | break 88 | } 89 | } 90 | result = append(result, resultStr) 91 | } 92 | 93 | if len(result) < firstBites.length{ 94 | for _, diff := range differences { 95 | if diff.lineNumber > len(result){ 96 | color := Red 97 | resultStr := fmt.Sprintf("%*d:%s - %s%s", maxLineNumLen, diff.lineNumber, color, diff.line, Reset) 98 | result = append(result, resultStr) 99 | } 100 | } 101 | } 102 | 103 | return result 104 | } 105 | 106 | func DiffBites(bites1, bites2 Bites) []Difference{ 107 | var differences []Difference 108 | maxLen := 0 109 | for _, line := range bites1.Content { 110 | if len(line) > maxLen { 111 | maxLen = len(line) 112 | } 113 | } 114 | 115 | lcsMatrix := make([][]int, len(bites1.Content)+1) 116 | for i := range lcsMatrix { 117 | lcsMatrix[i] = make([]int, len(bites2.Content)+1) 118 | } 119 | 120 | for i := 1; i <= len(bites1.Content); i++ { 121 | for j := 1; j <= len(bites2.Content); j++ { 122 | if bites1.Content[i-1] == bites2.Content[j-1] { 123 | lcsMatrix[i][j] = lcsMatrix[i-1][j-1] + 1 124 | } else { 125 | lcsMatrix[i][j] = max(lcsMatrix[i-1][j], lcsMatrix[i][j-1]) 126 | } 127 | } 128 | } 129 | 130 | i, j := len(bites1.Content), len(bites2.Content) 131 | for i > 0 || j > 0 { 132 | if i > 0 && j > 0 && bites1.Content[i-1] == bites2.Content[j-1] { 133 | i-- 134 | j-- 135 | } else if j > 0 && (i == 0 || lcsMatrix[i][j-1] >= lcsMatrix[i-1][j]) { 136 | differences = append([]Difference{{lineNumber: j, line: bites2.Content[j-1], Added: true}}, differences...) 137 | j-- 138 | } else if i > 0 && (j == 0 || lcsMatrix[i][j-1] < lcsMatrix[i-1][j]) { 139 | differences = append([]Difference{{lineNumber: i, line: bites1.Content[i-1], Added: false}}, differences...) 140 | i-- 141 | } 142 | } 143 | 144 | return differences 145 | } 146 | 147 | func check(e error) { 148 | if e != nil { 149 | panic(e) 150 | } 151 | } 152 | 153 | func max(a, b int) int { 154 | if a > b { 155 | return a 156 | } 157 | return b 158 | } 159 | 160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /pkg/openssh_conf.gtpl: -------------------------------------------------------------------------------- 1 | {{- /* 2 | Copyright (C) 2023 Shovra Nikita, Livitsky Andrey 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License.*/ -}} 15 | # ----------------------- 16 | # RATSKY SSHABU 17 | {{ with .Options }} 18 | {{ include "option" . }} 19 | {{- end -}} 20 | {{- range .Hosts -}} 21 | {{ include "host" . }} 22 | {{- end -}} 23 | {{- range .Groups -}} 24 | {{ include "group" . }} 25 | {{- end -}} 26 | {{- /*----------------------------------------------*/ -}} 27 | {{ define "group" }} 28 | {{- range .Hosts -}} 29 | {{ include "host" . }} 30 | {{- end -}} 31 | {{- range .Subgroups -}} 32 | {{ include "group" . }} 33 | {{- end -}} 34 | {{- end -}} 35 | {{- /*----------------------------------------------*/ -}} 36 | {{ define "host" }} 37 | {{- if .Options.Host }} 38 | Host {{ .Options.Host }} 39 | {{- else }} 40 | Host {{ .Name }} 41 | {{- end -}} 42 | {{- with .Options }} 43 | {{ include "option" . | indent 4 }} 44 | {{- end -}} 45 | {{- end -}} 46 | {{- /*----------------------------------------------*/ -}} 47 | {{ define "option" -}} 48 | {{- if .AddKeysToAgent }}AddKeysToAgent {{ .AddKeysToAgent }} 49 | {{ end }} 50 | {{- if .AddressFamily }}AddressFamily {{ .AddressFamily }} 51 | {{ end }} 52 | {{- if .BatchMode }}BatchMode {{ .BatchMode }} 53 | {{ end }} 54 | {{- if .BindAddress }}BindAddress {{ .BindAddress }} 55 | {{ end }} 56 | {{- if .CanonicalDomains }}CanonicalDomains {{ .CanonicalDomains }} 57 | {{ end }} 58 | {{- if .CanonicalizeFallbackLocal }}CanonicalizeFallbackLocal {{ .CanonicalizeFallbackLocal }} 59 | {{ end }} 60 | {{- if .CanonicalizeHostname }}CanonicalizeHostname {{ .CanonicalizeHostname }} 61 | {{ end }} 62 | {{- if .CanonicalizeMaxDots }}CanonicalizeMaxDots {{ .CanonicalizeMaxDots }} 63 | {{ end }} 64 | {{- if .CanonicalizePermittedCNAMEs }}CanonicalizePermittedCNAMEs {{ .CanonicalizePermittedCNAMEs }} 65 | {{ end }} 66 | {{- if .CASignatureAlgorithms }}CASignatureAlgorithms {{ .CASignatureAlgorithms }} 67 | {{ end }} 68 | {{- if .CertificateFile }}CertificateFile {{ .CertificateFile }} 69 | {{ end }} 70 | {{- if .CheckHostIP }}CheckHostIP {{ .CheckHostIP }} 71 | {{ end }} 72 | {{- if .Ciphers }}Ciphers {{ .Ciphers }} 73 | {{ end }} 74 | {{- if .ClearAllForwardings }}ClearAllForwardings {{ .ClearAllForwardings }} 75 | {{ end }} 76 | {{- if .Compression }}Compression {{ .Compression }} 77 | {{ end }} 78 | {{- if .ConnectionAttempts }}ConnectionAttempts {{ .ConnectionAttempts }} 79 | {{ end }} 80 | {{- if .ConnectTimeout }}ConnectTimeout {{ .ConnectTimeout }} 81 | {{ end }} 82 | {{- if .ControlMaster }}ControlMaster {{ .ControlMaster }} 83 | {{ end }} 84 | {{- if .ControlPath }}ControlPath {{ .ControlPath }} 85 | {{ end }} 86 | {{- if .ControlPersist }}ControlPersist {{ .ControlPersist }} 87 | {{ end }} 88 | {{- if .DynamicForward }}DynamicForward {{ .DynamicForward }} 89 | {{ end }} 90 | {{- if .EscapeChar }}EscapeChar {{ .EscapeChar }} 91 | {{ end }} 92 | {{- if .ExitOnForwardFailure }}ExitOnForwardFailure {{ .ExitOnForwardFailure }} 93 | {{ end }} 94 | {{- if .FingerprintHash }}FingerprintHash {{ .FingerprintHash }} 95 | {{ end }} 96 | {{- if .ForkAfterAuthentication }}ForkAfterAuthentication {{ .ForkAfterAuthentication }} 97 | {{ end }} 98 | {{- if .ForwardAgent }}ForwardAgent {{ .ForwardAgent }} 99 | {{ end }} 100 | {{- if .ForwardX11 }}ForwardX11 {{ .ForwardX11 }} 101 | {{ end }} 102 | {{- if .ForwardX11Timeout }}ForwardX11Timeout {{ .ForwardX11Timeout }} 103 | {{ end }} 104 | {{- if .ForwardX11Trusted }}ForwardX11Trusted {{ .ForwardX11Trusted }} 105 | {{ end }} 106 | {{- if .GatewayPorts }}GatewayPorts {{ .GatewayPorts }} 107 | {{ end }} 108 | {{- if .GlobalKnownHostsFile }}GlobalKnownHostsFile {{ .GlobalKnownHostsFile }} 109 | {{ end }} 110 | {{- if .GSSAPIAuthentication }}GSSAPIAuthentication {{ .GSSAPIAuthentication }} 111 | {{ end }} 112 | {{- if .GSSAPIDelegateCredentials }}GSSAPIDelegateCredentials {{ .GSSAPIDelegateCredentials }} 113 | {{ end }} 114 | {{- if .HashKnownHosts }}HashKnownHosts {{ .HashKnownHosts }} 115 | {{ end }} 116 | {{- if .HostbasedAcceptedAlgorithms }}HostbasedAcceptedAlgorithms {{ .HostbasedAcceptedAlgorithms }} 117 | {{ end }} 118 | {{- if .HostbasedAuthentication }}HostbasedAuthentication {{ .HostbasedAuthentication }} 119 | {{ end }} 120 | {{- if .HostKeyAlgorithms }}HostKeyAlgorithms {{ .HostKeyAlgorithms }} 121 | {{ end }} 122 | {{- if .HostKeyAlias }}HostKeyAlias {{ .HostKeyAlias }} 123 | {{ end }} 124 | {{- if .Hostname }}Hostname {{ .Hostname }} 125 | {{ end }} 126 | {{- if .IdentitiesOnly }}IdentitiesOnly {{ .IdentitiesOnly }} 127 | {{ end }} 128 | {{- if .IdentityAgent }}IdentityAgent {{ .IdentityAgent }} 129 | {{ end }} 130 | {{- if .IdentityFile }}IdentityFile {{ .IdentityFile }} 131 | {{ end }} 132 | {{- if .IPQoS }}IPQoS {{ .IPQoS }} 133 | {{ end }} 134 | {{- if .KbdInteractiveDevices }}KbdInteractiveDevices {{ .KbdInteractiveDevices }} 135 | {{ end }} 136 | {{- if .KexAlgorithms }}KexAlgorithms {{ .KexAlgorithms }} 137 | {{ end }} 138 | {{- if .KnownHostsCommand }}KnownHostsCommand {{ .KnownHostsCommand }} 139 | {{ end }} 140 | {{- if .LocalCommand }}LocalCommand {{ .LocalCommand }} 141 | {{ end }} 142 | {{- if .LocalForward }}LocalForward {{ .LocalForward }} 143 | {{ end }} 144 | {{- if .LogLevel -}}LogLevel {{ .LogLevel }} 145 | {{ end }} 146 | {{- if .MACs }}MACs {{ .MACs }} 147 | {{ end }} 148 | {{- if .NoHostAuthenticationForLocalhost }}NoHostAuthenticationForLocalhost {{ .NoHostAuthenticationForLocalhost }} 149 | {{ end }} 150 | {{- if .NumberOfPasswordPrompts }}NumberOfPasswordPrompts {{ .NumberOfPasswordPrompts }} 151 | {{ end }} 152 | {{- if .PasswordAuthentication }}PasswordAuthentication {{ .PasswordAuthentication }} 153 | {{ end }} 154 | {{- if .PermitLocalCommand }}PermitLocalCommand {{ .PermitLocalCommand }} 155 | {{ end }} 156 | {{- if .PermitRemoteOpen }}PermitRemoteOpen {{ .PermitRemoteOpen }} 157 | {{ end }} 158 | {{- if .PKCS11Provider }}PKCS11Provider {{ .PKCS11Provider }} 159 | {{ end }} 160 | {{- if .Port }}Port {{ .Port }} 161 | {{ end }} 162 | {{- if .PreferredAuthentications }}PreferredAuthentications {{ .PreferredAuthentications }} 163 | {{ end }} 164 | {{- if .ProxyCommand }}ProxyCommand {{ .ProxyCommand }} 165 | {{ end }} 166 | {{- if .ProxyJump }}ProxyJump {{ .ProxyJump }} 167 | {{ end }} 168 | {{- if .ProxyUseFdpass }}ProxyUseFdpass {{ .ProxyUseFdpass }} 169 | {{ end }} 170 | {{- if .PubkeyAcceptedAlgorithms }}PubkeyAcceptedAlgorithms {{ .PubkeyAcceptedAlgorithms }} 171 | {{ end }} 172 | {{- if .PubkeyAuthentication }}PubkeyAuthentication {{ .PubkeyAuthentication }} 173 | {{ end }} 174 | {{- if .RekeyLimit }}RekeyLimit {{ .RekeyLimit }} 175 | {{ end }} 176 | {{- if .RemoteCommand }}RemoteCommand {{ .RemoteCommand }} 177 | {{ end }} 178 | {{- if .RemoteForward }}RemoteForward {{ .RemoteForward }} 179 | {{ end }} 180 | {{- if .RequestTTY }}RequestTTY {{ .RequestTTY }} 181 | {{ end }} 182 | {{- if .SendEnv }}SendEnv {{ .SendEnv }} 183 | {{ end }} 184 | {{- if .ServerAliveInterval }}ServerAliveInterval {{ .ServerAliveInterval }} 185 | {{ end }} 186 | {{- if .ServerAliveCountMax }}ServerAliveCountMax {{ .ServerAliveCountMax }} 187 | {{ end }} 188 | {{- if .SessionType }}SessionType {{ .SessionType }} 189 | {{ end }} 190 | {{- if .SetEnv }}SetEnv {{ .SetEnv }} 191 | {{ end }} 192 | {{- if .StdinNull }}StdinNull {{ .StdinNull }} 193 | {{ end }} 194 | {{- if .StreamLocalBindMask }}StreamLocalBindMask {{.StreamLocalBindMask}} 195 | {{ end }} 196 | {{- if .StreamLocalBindUnlink }}StreamLocalBindUnlink {{.StreamLocalBindUnlink}} 197 | {{ end }} 198 | {{- if .StrictHostKeyChecking }}StrictHostKeyChecking {{.StrictHostKeyChecking}} 199 | {{ end }} 200 | {{- if .TCPKeepAlive }}TCPKeepAlive {{.TCPKeepAlive}} 201 | {{ end }} 202 | {{- if .Tunnel }}Tunnel {{.Tunnel}} 203 | {{ end }} 204 | {{- if .TunnelDevice }}TunnelDevice {{.TunnelDevice}} 205 | {{ end }} 206 | {{- if .UpdateHostKeys }}UpdateHostKeys {{.UpdateHostKeys}} 207 | {{ end }} 208 | {{- if .UseKeychain }}UseKeychain {{.UseKeychain}} 209 | {{ end }} 210 | {{- if .User }}User {{.User}} 211 | {{ end }} 212 | {{- if .UserKnownHostsFile }}UserKnownHostsFile {{.UserKnownHostsFile}} 213 | {{ end }} 214 | {{- if .VerifyHostKeyDNS }}VerifyHostKeyDNS {{.VerifyHostKeyDNS}} 215 | {{ end }} 216 | {{- if .VisualHostKey }}VisualHostKey {{.VisualHostKey}} 217 | {{ end }} 218 | {{- if .XAuthLocation }}XAuthLocation {{.XAuthLocation}} 219 | {{ end }} 220 | {{- end -}} -------------------------------------------------------------------------------- /pkg/types_test.go: -------------------------------------------------------------------------------- 1 | package sshabu 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func Test_inheritOptions(t *testing.T) { 9 | type args struct { 10 | item interface{} 11 | addition interface{} 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | }{ 17 | { 18 | name: "No changes when src has nil fields", 19 | args: args{ 20 | item: &Options{ // Your source object with some fields set to non-nil values 21 | AddressFamily: "ipv4", 22 | Port: 22, 23 | }, 24 | addition: &Options{ // Your destination object with some fields set to nil 25 | AddressFamily: nil, 26 | Port: nil, 27 | }, 28 | }, 29 | }, 30 | { 31 | name: "Copy non-nil fields from src to dst", 32 | args: args{ 33 | item: &Options{ // Your source object with all fields set to nil 34 | AddressFamily: nil, 35 | Port: nil, 36 | }, 37 | addition: &Options{ // Your destination object with some fields set to non-nil values 38 | AddressFamily: "ipv4", 39 | Port: 22, 40 | }, 41 | }, 42 | }, 43 | } 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | old_item := tt.args.item.(*Options) 47 | inheritOptions(tt.args.item, tt.args.addition) 48 | t.Log(old_item.AddressFamily) 49 | t.Log(tt.args.item.(*Options).AddressFamily) 50 | 51 | // Check if AddressFamily was copied correctly 52 | if old_item.AddressFamily == nil { 53 | // src.AddressFamily is nil, so dst.AddressFamily should remain nil 54 | if tt.args.addition.(*Options).AddressFamily != nil { 55 | t.Errorf("AddressFamily was not copied correctly") 56 | } 57 | } else if tt.args.item.(*Options).AddressFamily != old_item.AddressFamily { 58 | t.Errorf("AddressFamily was not copied correctly") 59 | } 60 | 61 | }) 62 | } 63 | } 64 | 65 | func TestShabu_Boil(t *testing.T) { 66 | type fields struct { 67 | Options Options 68 | Hosts []Host 69 | Groups []Group 70 | } 71 | tests := []struct { 72 | name string 73 | fields fields 74 | wantErr bool 75 | }{ 76 | { 77 | name: "Test with valid options and groups", 78 | fields: fields{ 79 | Options: Options{ 80 | AddressFamily: "ipv4", 81 | Port: 22, 82 | }, 83 | Hosts: []Host{ 84 | { 85 | Name: "Host1", 86 | }, 87 | }, 88 | Groups: []Group{ 89 | { 90 | Name: "Group1", 91 | Options: Options{ 92 | AddressFamily: "ipv4", 93 | }, 94 | Hosts: []Host{ 95 | { 96 | Name: "Host_in_group", 97 | }, 98 | }, 99 | }, 100 | }, 101 | }, 102 | wantErr: false, 103 | }, 104 | { 105 | name: "Invalid Name fields", 106 | fields: fields{ 107 | Options: Options{ 108 | AddressFamily: "ipv4", 109 | Port: 22, 110 | }, 111 | Hosts: []Host{ 112 | { 113 | Name: "Host1", 114 | }, 115 | }, 116 | Groups: []Group{ 117 | { 118 | Name: "Host1", 119 | Options: Options{ 120 | AddressFamily: "ipv4", 121 | }, 122 | Hosts: []Host{ 123 | { 124 | Name: "Host_in_group", 125 | }, 126 | }, 127 | }, 128 | }, 129 | }, 130 | wantErr: true, 131 | }, 132 | // Other cases 133 | } 134 | for _, tt := range tests { 135 | t.Run(tt.name, func(t *testing.T) { 136 | shabu := &Shabu{ 137 | Options: tt.fields.Options, 138 | Hosts: tt.fields.Hosts, 139 | Groups: tt.fields.Groups, 140 | } 141 | err := shabu.Boil() 142 | t.Log(shabu.Groups[0].Hosts[0].Options.AddressFamily) 143 | if (err != nil) != tt.wantErr { 144 | t.Errorf("Shabu.Boil() error = %v, wantErr %v", err, tt.wantErr) 145 | } 146 | }) 147 | } 148 | } 149 | 150 | func TestHost_inheritOptions(t *testing.T) { 151 | type fields struct { 152 | Options Options 153 | Name string 154 | } 155 | type args struct { 156 | groupOptions Options 157 | } 158 | tests := []struct { 159 | name string 160 | fields fields 161 | args args 162 | wantErr bool 163 | }{ 164 | { 165 | name: "Test with valid options and groups", 166 | fields: fields{ 167 | Options: Options{ 168 | AddressFamily: "ipv4", 169 | }, 170 | Name: "Host123", 171 | }, 172 | args: args{ 173 | groupOptions: Options{ 174 | User: "lvtsky", 175 | }, 176 | }, 177 | wantErr: false, 178 | }, 179 | // Add more test cases if needed 180 | } 181 | for _, tt := range tests { 182 | t.Run(tt.name, func(t *testing.T) { 183 | host := &Host{ 184 | Options: tt.fields.Options, 185 | Name: tt.fields.Name, 186 | } 187 | if err := host.inheritOptions(tt.args.groupOptions); (err != nil) != tt.wantErr { 188 | t.Errorf("Host.inheritOptions() error = %v, wantErr %v", err, tt.wantErr) 189 | } 190 | 191 | // Add assertions to verify that options were inherited correctly 192 | if host.Options.User != "lvtsky" { 193 | t.Errorf("User field was not inherited correctly") 194 | } 195 | }) 196 | } 197 | } 198 | 199 | func TestGroup_inheritOptions(t *testing.T) { 200 | type fields struct { 201 | Options Options 202 | Hosts []Host 203 | Name string 204 | Subgroups []Group 205 | } 206 | type args struct { 207 | parentOptions Options 208 | } 209 | tests := []struct { 210 | name string 211 | fields fields 212 | args args 213 | wantErr bool 214 | }{ 215 | { 216 | name: "Test with valid parent options", 217 | fields: fields{ 218 | Options: Options{ 219 | User: "lvtsky", 220 | }, 221 | Name: "Group123", 222 | Subgroups: []Group{ 223 | { 224 | Name: "Subgroup1", 225 | Options: Options{ 226 | Port: 22, 227 | }, 228 | }, 229 | }, 230 | }, 231 | args: args{ 232 | parentOptions: Options{ 233 | AddressFamily: "ipv4", 234 | }, 235 | }, 236 | wantErr: false, 237 | }, 238 | // Add more test cases if needed 239 | } 240 | for _, tt := range tests { 241 | t.Run(tt.name, func(t *testing.T) { 242 | group := &Group{ 243 | Options: tt.fields.Options, 244 | Hosts: tt.fields.Hosts, 245 | Name: tt.fields.Name, 246 | Subgroups: tt.fields.Subgroups, 247 | } 248 | if err := group.inheritOptions(tt.args.parentOptions); (err != nil) != tt.wantErr { 249 | t.Errorf("Group.inheritOptions() error = %v, wantErr %v", err, tt.wantErr) 250 | } 251 | 252 | // Add assertions to verify that options were inherited correctly 253 | if group.Options.User != "lvtsky" { 254 | t.Errorf("User field was not inherited correctly") 255 | } 256 | 257 | // Add similar checks for other fields or nested structures 258 | }) 259 | } 260 | } 261 | 262 | func TestGroup_solveGroup(t *testing.T) { 263 | type fields struct { 264 | Options Options 265 | Hosts []Host 266 | Name string 267 | Subgroups []Group 268 | } 269 | type args struct { 270 | parentOptions Options 271 | } 272 | tests := []struct { 273 | name string 274 | fields fields 275 | args args 276 | wantErr bool 277 | }{ 278 | { 279 | name: "Test with valid options and subgroups", 280 | fields: fields{ 281 | Options: Options{ 282 | User: "lvtsky", 283 | }, 284 | Name: "Group123", 285 | Subgroups: []Group{ 286 | { 287 | Name: "Subgroup1", 288 | Options: Options{ 289 | Port: 22, 290 | }, 291 | }, 292 | }, 293 | }, 294 | args: args{ 295 | parentOptions: Options{ 296 | AddressFamily: "ipv4", 297 | }, 298 | }, 299 | wantErr: false, 300 | }, 301 | // Add more test cases if needed 302 | } 303 | for _, tt := range tests { 304 | t.Run(tt.name, func(t *testing.T) { 305 | group := &Group{ 306 | Options: tt.fields.Options, 307 | Hosts: tt.fields.Hosts, 308 | Name: tt.fields.Name, 309 | Subgroups: tt.fields.Subgroups, 310 | } 311 | if err := group.solveGroup(tt.args.parentOptions); (err != nil) != tt.wantErr { 312 | t.Errorf("Group.solveGroup() error = %v, wantErr %v", err, tt.wantErr) 313 | } 314 | 315 | // Add assertions to verify that options were inherited correctly 316 | if group.Options.User != "lvtsky" { 317 | t.Errorf("User field was not inherited correctly") 318 | } 319 | 320 | // Add similar checks for other fields or nested structures 321 | }) 322 | } 323 | } 324 | 325 | func TestShabu_FindNamesInShabu(t *testing.T) { 326 | type fields struct { 327 | Options Options 328 | Hosts []Host 329 | Groups []Group 330 | } 331 | tests := []struct { 332 | name string 333 | fields fields 334 | want []string 335 | }{ 336 | { 337 | name: "Test with valid names in Shabu", 338 | fields: fields{ 339 | Options: Options{ 340 | User: "lvtsky", 341 | }, 342 | Hosts: []Host{ 343 | { 344 | Name: "Host1", 345 | }, 346 | }, 347 | Groups: []Group{ 348 | { 349 | Name: "Group1", 350 | }, 351 | { 352 | Name: "Group2", 353 | }, 354 | }, 355 | }, 356 | want: []string{"Host1", "Group1", "Group2"}, 357 | }, 358 | // Add more test cases if needed 359 | } 360 | for _, tt := range tests { 361 | t.Run(tt.name, func(t *testing.T) { 362 | shabu := Shabu{ 363 | Options: tt.fields.Options, 364 | Hosts: tt.fields.Hosts, 365 | Groups: tt.fields.Groups, 366 | } 367 | if got := shabu.FindNamesInShabu(); !reflect.DeepEqual(got, tt.want) { 368 | t.Errorf("Shabu.FindNamesInShabu() = %v, want %v", got, tt.want) 369 | } 370 | }) 371 | } 372 | } 373 | 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ratsky Sshabu 2 | 3 | 4 | # Table of contents 5 | 6 | - [Overview](#overview) 7 | - [Quick start](#quick-start) 8 | - [Installation](#installation) 9 | - [Commands](#commands) 10 | - [Configuration](#configuration) 11 | - [License](#license) 12 | - [Contacts](#contact) 13 | 14 | # Overview 15 | 16 | `Ratsky Sshabu` is a robust SSH client management tool designed to streamline the process of connecting to multiple servers effortlessly. This tool leverages OpenSSH and offers a user-friendly interface to enhance the overall SSH experience. With Sshabu, managing SSH configurations becomes more intuitive, allowing users to organize and connect to their servers efficiently. 17 | 18 | alt text 19 | 20 | > Openssh should be installed on your system. 21 | 22 | # Quick start 23 | 1. Install via brew 24 | ```bash 25 | brew install ratsky-oss/taps/sshabu 26 | ``` 27 | > Or download binary and move in $PATH by yourself 28 | > - Download the binary file `sshabu`. You can view them on > the release page: 29 | > https://github.com/Ra-sky/sshabu/releases 30 | > ```bash 31 | > wget https://github.com/ratsky-oss/sshabu/releases/download/v0.1.2/sshabu_Linux_arm64.tar.gz 32 | > ``` 33 | > - Unzip and move binary file `sshabu` to `/usr/local/bin/`. 34 | > ```bash 35 | > mkdir sshabu_Darwin_arm64 && tar -xvzf sshabu_Darwin_arm64.> tar.gz -C sshabu_Darwin_arm64 && \ 36 | > cd sshabu_Darwin_arm64 && \ 37 | > mv sshabu /usr/local/bin/sshabu 38 | > ``` 39 | 2. Initialize your sshabu configuration. 40 | ```bash 41 | sshabu init 42 | ``` 43 | 3. Enable auto-completion. Several options are available; use the following command to view them: 44 | ```bash 45 | sshabu completion --help 46 | ``` 47 | 4. Begin editing the config with this convenient command: 48 | ```bash 49 | sshabu edit 50 | ``` 51 | 5. Connect to servers by specifying the name. 52 | ```bash 53 | sshabu connect 54 | ``` 55 | 6. Yep-Yep-Yep! It's time for shabu! 56 | 57 | # Installation 58 | ## Brew 59 | 1. Adding Ratsky third-party repository 60 | ```bash 61 | brew tap ratsky-oss/taps 62 | ``` 63 | 2. Installing sshabu 64 | ``` 65 | brew install sshabu 66 | ``` 67 | 3. Validate sshabu binary 68 | ``` 69 | which sshabu 70 | ``` 71 | 4. Initialize your `sshabu` configuration. 72 | ```bash 73 | sshabu init 74 | ``` 75 | ## Easy 76 | 1. Download the binary file `sshabu` to `/usr/bin/sshabu`. You can change the default path from `/usr/bin/sshabu` to your own, but make sure that the path is included in your `PATH` environment variable. 77 | 80 | 2. Initialize your `sshabu` configuration. 81 | ```bash 82 | sshabu init 83 | ``` 84 | ## Build from source 85 | 1. Clone the git repository. 86 | 89 | 2. Change the directory to the cloned project. 90 | ```bash 91 | cd ./sshabu 92 | ``` 93 | 3. Build the project. 94 | ```bash 95 | go build . 96 | ``` 97 | 4. Move the binary file `sshabu`. You can change the default path from `/usr/bin/sshabu` to your own, but make sure that the path is included in your `PATH` environment variable. 98 | ```bash 99 | mv sshabu /usr/bin/sshabu 100 | ``` 101 | 5. Initialize your `sshabu` configuration. 102 | ```bash 103 | sshabu init 104 | ``` 105 | 106 | ## Commands 107 | 108 | #### `sshabu init` 109 | Create `~/$HOME/.sshabu/` directory and generate example `sshabu.yaml` config 110 | #### `sshabu apply` 111 | Generate `openssh.config` based on `sshabu.yaml` 112 | #### `sshabu edit` 113 | Open `sshabu.yaml` with editor and runs `sshabu apply after that` 114 | #### `sshabu connect` 115 | Runs openssh command with `openssh.config` 116 | 117 | > Find out more info by using `--help` flag 118 | 119 | ## Configuration 120 | 121 | All unmentioned options will be inherited from parent(s) groups 'till top group in derictive 122 | 123 | > ~/.sshabu/sshabu.yaml 124 | 125 | Config structure 126 | 127 | ``` 128 | GlobalOptions: 129 |