├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── archive └── tar.go ├── cli └── command │ ├── cfg │ └── cfg.go │ ├── cli.go │ ├── key │ ├── add.go │ ├── copy.go │ ├── generate.go │ ├── key.go │ ├── list.go │ └── remove.go │ ├── logger.go │ ├── machine │ ├── add.go │ ├── copy.go │ ├── execute.go │ ├── list.go │ ├── machine.go │ └── remove.go │ ├── name.go │ ├── prompter.go │ └── tablePrinter.go ├── config ├── config.go ├── config_file.go ├── key.go ├── machine.go └── profile.go ├── go.mod ├── go.sum ├── main.go ├── sshexec ├── client.go ├── cmd.go └── executor.go └── streams ├── in.go ├── out.go └── stream.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: Release 3 | 4 | on: 5 | push: 6 | tags: 7 | - "*" 8 | 9 | permissions: 10 | contents: write 11 | packages: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: stable 25 | 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | distribution: goreleaser 30 | version: "latest" 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | *.test 8 | 9 | *.out 10 | 11 | go.work 12 | go.work.sum 13 | 14 | # env file 15 | .env 16 | 17 | dist/ 18 | -------------------------------------------------------------------------------- /.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: 2 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 | builds: 19 | - env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - linux 23 | - windows 24 | - darwin 25 | ldflags: 26 | - -s -w -X main.version={{.Version}} 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 | sort: asc 45 | filters: 46 | exclude: 47 | - "^docs:" 48 | - "^test:" 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 d3witt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Viking ⛵️ 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/d3witt/viking)](https://goreportcard.com/report/github.com/d3witt/viking) 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/d3witt/viking.svg)](https://pkg.go.dev/github.com/d3witt/viking) 4 | ![GitHub release](https://img.shields.io/github/v/release/d3witt/viking) 5 | 6 | 7 | ### Simple way to manage your remote machines 8 | 9 | Bare metal servers are awesome. They let you pick where to run your software and how to deploy it. You get full control to make the most of the server's resources. No limits, no compromises. That's real freedom. 10 | 11 | Viking makes it easier to work with them. 12 | 13 | ``` 14 | NAME: 15 | viking - Manage your SSH keys and remote machines 16 | 17 | USAGE: 18 | viking [global options] command [command options] 19 | 20 | VERSION: 21 | v1.0 22 | 23 | COMMANDS: 24 | exec Execute shell command on machine 25 | copy, cp Copy files/folders between local and remote machine 26 | key Manage SSH keys 27 | machine Manage your machines 28 | config Get config directory path 29 | help, h Shows a list of commands or help for one command 30 | 31 | GLOBAL OPTIONS: 32 | --help, -h show help 33 | --version, -v print the version 34 | ``` 35 | 36 | ## 🚀 Installation 37 | 38 | See [releases](https://github.com/d3witt/viking/releases) for pre-built binaries. 39 | 40 | On Unix: 41 | 42 | ``` 43 | env CGO_ENABLED=0 go install -ldflags="-s -w" github.com/d3witt/viking@latest 44 | ``` 45 | 46 | On Windows cmd: 47 | 48 | ``` 49 | set CGO_ENABLED=0 50 | go install -ldflags="-s -w" github.com/d3witt/viking@latest 51 | ``` 52 | 53 | On Windows powershell: 54 | 55 | ``` 56 | $env:CGO_ENABLED = '0' 57 | go install -ldflags="-s -w" github.com/d3witt/viking@latest 58 | ``` 59 | 60 | ## 📄 Usage 61 | 62 | #### 🛰️ Add machine: 63 | 64 | ``` 65 | $ viking machine add --name deathstar --key starkey 168.112.216.50 root@61.22.128.69:3000 73.30.62.32:3001 66 | Machine deathstar added. 67 | ``` 68 | 69 | > [!NOTE] 70 | > The key flag is not required. If a key is not specified, SSH Agent will be used to connect to the server. 71 | 72 | #### 📡 Exec command (in parallel on all machines): 73 | 74 | ``` 75 | $ viking exec deathstar echo 1234 76 | 168.112.216.50: 1234 77 | 61.22.128.69: 1234 78 | 73.30.62.32: 1234 79 | ``` 80 | 81 | #### 📺 Connect to the machine: 82 | 83 | ``` 84 | $ viking exec --tty deathstar /bin/bash 85 | root@deathstar:~$ 86 | ``` 87 | 88 | #### 🗂️ Copy files/directories (in parallel to/from all machines): 89 | 90 | ``` 91 | $ viking cp /tmp/file.txt deathstar:/tmp/ 92 | Success: 3, Errors: 0 93 | ``` 94 | 95 | #### 🔑 Add SSH key from a file 96 | 97 | ``` 98 | $ viking key add --name starkey --passphrase dart ./id_rsa_star 99 | Key starkey added. 100 | ``` 101 | 102 | #### 🆕 Generate SSH Key 103 | 104 | ``` 105 | $ viking key generate --name starkey2 106 | Key starkey2 added. 107 | ``` 108 | 109 | #### 📋 Copy public SSH Key 110 | 111 | ``` 112 | $ viking key copy starkey2 113 | Public key copied to your clipboard. 114 | ``` 115 | 116 | #### ⚙️ Custom config directory 117 | 118 | Viking saves data locally. Set `VIKING_CONFIG_DIR` env variable for a custom directory. Use `viking config` to check the current config folder. 119 | 120 | ## 🤝 Missing a Feature? 121 | 122 | Feel free to open a new issue, or contact me. 123 | 124 | ## 📘 License 125 | 126 | Viking is provided under the [MIT License](https://github.com/d3witt/viking/blob/main/LICENSE). 127 | -------------------------------------------------------------------------------- /archive/tar.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "archive/tar" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/d3witt/viking/sshexec" 11 | ) 12 | 13 | func Tar(source string) (io.Reader, error) { 14 | pr, pw := io.Pipe() 15 | tw := tar.NewWriter(pw) 16 | 17 | go func() { 18 | defer func() { 19 | if err := tw.Close(); err != nil { 20 | fmt.Println("Error closing tar writer:", err) 21 | } 22 | pw.Close() // Close the pipe writer when done 23 | }() 24 | 25 | fi, err := os.Stat(source) 26 | if err != nil { 27 | pw.CloseWithError(err) 28 | return 29 | } 30 | 31 | if fi.IsDir() { 32 | err = filepath.Walk(source, func(filePath string, fi os.FileInfo, err error) error { 33 | if err != nil { 34 | return err 35 | } 36 | 37 | // Construct the header 38 | relPath, err := filepath.Rel(source, filePath) 39 | if err != nil { 40 | return err 41 | } 42 | header, err := tar.FileInfoHeader(fi, "") 43 | if err != nil { 44 | return err 45 | } 46 | 47 | // Use relative path to avoid including the entire source directory structure 48 | header.Name = relPath 49 | 50 | // Write the header 51 | if err := tw.WriteHeader(header); err != nil { 52 | return err 53 | } 54 | 55 | // If it's a regular file, write its content to the tar writer 56 | if fi.Mode().IsRegular() { 57 | file, err := os.Open(filePath) 58 | if err != nil { 59 | return err 60 | } 61 | defer file.Close() 62 | 63 | if _, err := io.Copy(tw, file); err != nil { 64 | return err 65 | } 66 | } 67 | 68 | return nil 69 | }) 70 | } else { 71 | // Handle the case where source is a single file 72 | header, err := tar.FileInfoHeader(fi, "") 73 | if err != nil { 74 | pw.CloseWithError(err) 75 | return 76 | } 77 | 78 | // Only set the base name for a single file 79 | header.Name = filepath.Base(source) 80 | 81 | // Write the header 82 | if err := tw.WriteHeader(header); err != nil { 83 | pw.CloseWithError(err) 84 | return 85 | } 86 | 87 | // Write the file content 88 | file, err := os.Open(source) 89 | if err != nil { 90 | pw.CloseWithError(err) 91 | return 92 | } 93 | defer file.Close() 94 | 95 | if _, err := io.Copy(tw, file); err != nil { 96 | pw.CloseWithError(err) 97 | return 98 | } 99 | } 100 | 101 | if err != nil { 102 | pw.CloseWithError(err) // Close the pipe with an error if it occurs 103 | } 104 | }() 105 | 106 | return pr, nil 107 | } 108 | 109 | func Untar(r io.Reader, dest string) error { 110 | tr := tar.NewReader(r) 111 | 112 | for { 113 | header, err := tr.Next() 114 | if err == io.EOF { 115 | break // End of tar archive 116 | } 117 | if err != nil { 118 | return err 119 | } 120 | 121 | // Create the file or directory 122 | target := filepath.Join(dest, header.Name) 123 | switch header.Typeflag { 124 | case tar.TypeDir: 125 | if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { 126 | return err 127 | } 128 | case tar.TypeReg: 129 | dir := filepath.Dir(target) 130 | if err := os.MkdirAll(dir, 0o755); err != nil { 131 | return err 132 | } 133 | file, err := os.OpenFile(target, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(header.Mode)) 134 | if err != nil { 135 | return err 136 | } 137 | defer file.Close() 138 | if _, err := io.Copy(file, tr); err != nil { 139 | return err 140 | } 141 | default: 142 | continue 143 | } 144 | } 145 | 146 | return nil 147 | } 148 | 149 | // TarRemote creates a tar archive for the given file/directory on the remote server. 150 | func TarRemote(exec sshexec.Executor, source string) (io.Reader, error) { 151 | outPipe, inPipe := io.Pipe() 152 | 153 | go func() { 154 | defer inPipe.Close() 155 | cmd := sshexec.Command(exec, "tar", "-cf", "-", source, ".") 156 | cmd.Stdout = inPipe 157 | if err := cmd.Run(); err != nil { 158 | inPipe.CloseWithError(err) 159 | } 160 | }() 161 | 162 | return outPipe, nil 163 | } 164 | 165 | func UntarRemote(exec sshexec.Executor, dest string, in io.Reader) error { 166 | folderPath := filepath.Dir(dest) 167 | 168 | // Ensure the destination directory exists 169 | cmd := sshexec.Command(exec, "mkdir", "-p", folderPath) 170 | if err := cmd.Run(); err != nil { 171 | return fmt.Errorf("failed to create directory: %w", err) 172 | } 173 | 174 | // Untar the contents to the destination directory, replacing existing files 175 | cmd = sshexec.Command(exec, "tar", "--overwrite", "-xf", "-", "-C", folderPath) 176 | cmd.Stdin = in 177 | 178 | return cmd.Run() 179 | } 180 | -------------------------------------------------------------------------------- /cli/command/cfg/cfg.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/d3witt/viking/cli/command" 7 | "github.com/d3witt/viking/config" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func NewConfigCmd(vikingCli *command.Cli) *cli.Command { 13 | return &cli.Command{ 14 | Name: "config", 15 | Usage: "Get config directory path", 16 | Action: func(ctx *cli.Context) error { 17 | path, err := config.ConfigDir() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | fmt.Fprintln(vikingCli.Out, path) 23 | return nil 24 | }, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cli/command/cli.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/d3witt/viking/config" 7 | "github.com/d3witt/viking/sshexec" 8 | "github.com/d3witt/viking/streams" 9 | ) 10 | 11 | type Cli struct { 12 | Config *config.Config 13 | Out, Err *streams.Out 14 | In *streams.In 15 | CmdLogger *slog.Logger 16 | } 17 | 18 | func (c *Cli) MachineExecuters(machine string) ([]sshexec.Executor, error) { 19 | m, err := c.Config.GetMachineByName(machine) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | execs := make([]sshexec.Executor, len(m.Hosts)) 25 | for i, host := range m.Hosts { 26 | exec, err := c.HostExecutor(host) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | execs[i] = exec 32 | } 33 | 34 | return execs, nil 35 | } 36 | 37 | func (c *Cli) HostExecutor(host config.Host) (sshexec.Executor, error) { 38 | var private, passphrase string 39 | if host.Key != "" { 40 | key, err := c.Config.GetKeyByName(host.Key) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | private = key.Private 46 | passphrase = key.Passphrase 47 | } 48 | 49 | return sshexec.NewExecutor(host.IP.String(), host.Port, host.User, private, passphrase), nil 50 | } 51 | -------------------------------------------------------------------------------- /cli/command/key/add.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/d3witt/viking/cli/command" 9 | "github.com/d3witt/viking/config" 10 | "github.com/urfave/cli/v2" 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | func NewAddCmd(vikingCli *command.Cli) *cli.Command { 15 | return &cli.Command{ 16 | Name: "add", 17 | Usage: "Add a new ssh key from file", 18 | Args: true, 19 | ArgsUsage: "FILE_PATH", 20 | Flags: []cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "name", 23 | Usage: "Key name", 24 | Aliases: []string{"n"}, 25 | }, 26 | &cli.StringFlag{ 27 | Name: "passphrase", 28 | Usage: "Key passphrase", 29 | Aliases: []string{"p"}, 30 | }, 31 | }, 32 | Action: func(ctx *cli.Context) error { 33 | path := ctx.Args().First() 34 | name := ctx.String("name") 35 | passphrase := ctx.String("passphrase") 36 | 37 | return runAdd(vikingCli, path, name, passphrase) 38 | }, 39 | } 40 | } 41 | 42 | func runAdd(vikingCli *command.Cli, path, name, passphrase string) error { 43 | data, err := os.ReadFile(path) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | var privateKey ssh.Signer 49 | 50 | if passphrase == "" { 51 | privateKey, err = ssh.ParsePrivateKey(data) 52 | } else { 53 | privateKey, err = ssh.ParsePrivateKeyWithPassphrase(data, []byte(passphrase)) 54 | } 55 | 56 | if err != nil { 57 | return err 58 | } 59 | 60 | publicKey := ssh.MarshalAuthorizedKey(privateKey.PublicKey()) 61 | 62 | if name == "" { 63 | name = command.GenerateRandomName() 64 | } 65 | 66 | if err := vikingCli.Config.AddKey( 67 | config.Key{ 68 | Name: name, 69 | Private: string(data), 70 | Public: string(publicKey), 71 | Passphrase: passphrase, 72 | CreatedAt: time.Now(), 73 | }, 74 | ); err != nil { 75 | return err 76 | } 77 | 78 | fmt.Fprintf(vikingCli.Out, "Key %s added.\n", name) 79 | 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /cli/command/key/copy.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/d3witt/viking/cli/command" 7 | "github.com/urfave/cli/v2" 8 | "golang.design/x/clipboard" 9 | ) 10 | 11 | func NewCopyCmd(vikingCli *command.Cli) *cli.Command { 12 | return &cli.Command{ 13 | Name: "copy", 14 | Usage: "Copy public key to clipboard.", 15 | Args: true, 16 | ArgsUsage: "NAME", 17 | Action: func(ctx *cli.Context) error { 18 | name := ctx.Args().First() 19 | 20 | return runCopy(vikingCli, name) 21 | }, 22 | } 23 | } 24 | 25 | func runCopy(vikingCli *command.Cli, name string) error { 26 | key, err := vikingCli.Config.GetKeyByName(name) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | err = clipboard.Init() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | clipboard.Write(clipboard.FmtText, []byte(key.Public)) 37 | fmt.Fprintln(vikingCli.Out, "Public key copied to your clipboard.") 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /cli/command/key/generate.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/d3witt/viking/cli/command" 12 | "github.com/d3witt/viking/config" 13 | "github.com/urfave/cli/v2" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | func NewGenerateCmd(vikingCli *command.Cli) *cli.Command { 18 | return &cli.Command{ 19 | Name: "generate", 20 | Usage: "Generate a new SSH key", 21 | Flags: []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "name", 24 | Usage: "Key name", 25 | Aliases: []string{"n"}, 26 | }, 27 | }, 28 | Action: func(ctx *cli.Context) error { 29 | name := ctx.String("name") 30 | 31 | return runGenerate(vikingCli, name) 32 | }, 33 | } 34 | } 35 | 36 | func runGenerate(vikingCli *command.Cli, name string) error { 37 | if name == "" { 38 | name = command.GenerateRandomName() 39 | } 40 | 41 | private, public, err := generateSSHKeyPair() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | if err = vikingCli.Config.AddKey( 47 | config.Key{ 48 | Name: name, 49 | Private: string(private), 50 | Public: string(public), 51 | CreatedAt: time.Now(), 52 | }, 53 | ); err != nil { 54 | return err 55 | } 56 | 57 | fmt.Fprintf(vikingCli.Out, "Key %s added.\n", name) 58 | 59 | return nil 60 | } 61 | 62 | func generateSSHKeyPair() (privateKey, publicKey string, err error) { 63 | privateRSAKey, err := rsa.GenerateKey(rand.Reader, 2048) 64 | if err != nil { 65 | return "", "", err 66 | } 67 | 68 | // Generate the private key PEM block. 69 | privatePEMBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateRSAKey)} 70 | 71 | // Encode the private key to PEM format. 72 | privateKeyBytes := pem.EncodeToMemory(privatePEMBlock) 73 | privateKey = string(privateKeyBytes) 74 | 75 | // Generate the public key for the private key. 76 | publicRSAKey, err := ssh.NewPublicKey(&privateRSAKey.PublicKey) 77 | if err != nil { 78 | return "", "", err 79 | } 80 | 81 | // Encode the public key to the authorized_keys format. 82 | publicKeyBytes := ssh.MarshalAuthorizedKey(publicRSAKey) 83 | publicKey = string(publicKeyBytes) 84 | 85 | return privateKey, publicKey, nil 86 | } 87 | -------------------------------------------------------------------------------- /cli/command/key/key.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "github.com/d3witt/viking/cli/command" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | func NewCmd(vikingCli *command.Cli) *cli.Command { 9 | return &cli.Command{ 10 | Name: "key", 11 | Usage: "Manage SSH keys", 12 | Subcommands: []*cli.Command{ 13 | NewAddCmd(vikingCli), 14 | NewListCmd(vikingCli), 15 | NewRmCmd(vikingCli), 16 | NewGenerateCmd(vikingCli), 17 | NewCopyCmd(vikingCli), 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cli/command/key/list.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/d3witt/viking/cli/command" 7 | "github.com/dustin/go-humanize" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func NewListCmd(vikingCli *command.Cli) *cli.Command { 12 | return &cli.Command{ 13 | Name: "ls", 14 | Usage: "List all SSH keys", 15 | Action: func(ctx *cli.Context) error { 16 | return listKeys(vikingCli) 17 | }, 18 | } 19 | } 20 | 21 | func listKeys(vikingCli *command.Cli) error { 22 | keys := vikingCli.Config.ListKeys() 23 | 24 | sort.Slice(keys, func(i, j int) bool { 25 | return keys[i].CreatedAt.After(keys[j].CreatedAt) 26 | }) 27 | 28 | data := [][]string{ 29 | { 30 | "NAME", 31 | "CREATED", 32 | }, 33 | } 34 | 35 | for _, machine := range keys { 36 | data = append(data, []string{ 37 | machine.Name, 38 | humanize.Time(machine.CreatedAt), 39 | }) 40 | } 41 | 42 | command.PrintTable(vikingCli.Out, data) 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /cli/command/key/remove.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/d3witt/viking/cli/command" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | func NewRmCmd(vikingCli *command.Cli) *cli.Command { 11 | return &cli.Command{ 12 | Name: "rm", 13 | Usage: "Remove a key", 14 | Args: true, 15 | ArgsUsage: "NAME", 16 | Action: func(ctx *cli.Context) error { 17 | name := ctx.Args().First() 18 | return runRemove(vikingCli, name) 19 | }, 20 | } 21 | } 22 | 23 | func runRemove(vikingCli *command.Cli, name string) error { 24 | if err := vikingCli.Config.RemoveKey(name); err != nil { 25 | return err 26 | } 27 | 28 | fmt.Fprintln(vikingCli.Out, "Key removed from this computer.") 29 | 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /cli/command/logger.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "log/slog" 9 | "strings" 10 | ) 11 | 12 | type CmdLogHandler struct { 13 | slog.Handler 14 | logger *log.Logger 15 | } 16 | 17 | func (h *CmdLogHandler) Handle(ctx context.Context, r slog.Record) error { 18 | var sb strings.Builder 19 | r.Attrs(func(a slog.Attr) bool { 20 | sb.WriteString(fmt.Sprintf("%s=\"%s\" ", a.Key, fmt.Sprintf("%v", a.Value.Any()))) 21 | return true 22 | }) 23 | 24 | if sb.Len() > 0 { 25 | h.logger.Printf("%s: %s", r.Message, sb.String()) 26 | } else { 27 | h.logger.Println(r.Message) 28 | } 29 | return nil 30 | } 31 | 32 | func NewCmdLogHandler( 33 | out io.Writer, 34 | opts *slog.HandlerOptions, 35 | ) *CmdLogHandler { 36 | h := &CmdLogHandler{ 37 | Handler: slog.NewTextHandler(out, opts), 38 | logger: log.New(out, "", 0), 39 | } 40 | 41 | return h 42 | } 43 | -------------------------------------------------------------------------------- /cli/command/machine/add.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/d3witt/viking/cli/command" 12 | "github.com/d3witt/viking/config" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | func NewAddCmd(vikingCli *command.Cli) *cli.Command { 17 | return &cli.Command{ 18 | Name: "add", 19 | Usage: "Add a new machine", 20 | Description: "This command adds a new machine to the list of machines. No action is taken on the machine itself. Ensure your computer has SSH access to this machine.", 21 | Args: true, 22 | ArgsUsage: "[USER@]HOST[:PORT]...", 23 | Flags: []cli.Flag{ 24 | &cli.StringFlag{ 25 | Name: "name", 26 | Aliases: []string{"n"}, 27 | Usage: "Machine name", 28 | }, 29 | &cli.StringFlag{ 30 | Name: "user", 31 | Aliases: []string{"u"}, 32 | Value: "root", 33 | Usage: "SSH user name", 34 | }, 35 | &cli.StringFlag{ 36 | Name: "key", 37 | Aliases: []string{"k"}, 38 | Usage: "SSH key name", 39 | }, 40 | &cli.IntFlag{ 41 | Name: "port", 42 | Aliases: []string{"p"}, 43 | Value: 22, 44 | }, 45 | }, 46 | Action: func(ctx *cli.Context) error { 47 | hosts := ctx.Args().Slice() 48 | name := ctx.String("name") 49 | user := ctx.String("user") 50 | key := ctx.String("key") 51 | port := ctx.Int("port") 52 | 53 | return runAdd(vikingCli, hosts, port, name, user, key) 54 | }, 55 | } 56 | } 57 | 58 | func parseMachine(val, defaultUser string, defaultPort int) (user string, ip net.IP, port int, err error) { 59 | user = defaultUser 60 | port = defaultPort 61 | 62 | if idx := strings.Index(val, "@"); idx != -1 { 63 | user = val[:idx] 64 | val = val[idx+1:] 65 | } 66 | 67 | host, portStr, splitErr := net.SplitHostPort(val) 68 | if splitErr != nil { 69 | host = val 70 | } else { 71 | port, err = strconv.Atoi(portStr) 72 | if err != nil { 73 | err = errors.New("invalid port number") 74 | return 75 | } 76 | } 77 | 78 | ip = net.ParseIP(host) 79 | if ip == nil { 80 | err = errors.New("invalid IP address") 81 | } 82 | 83 | return 84 | } 85 | 86 | func runAdd(vikingCli *command.Cli, hosts []string, port int, name, user, key string) error { 87 | if name == "" { 88 | name = command.GenerateRandomName() 89 | } 90 | 91 | if key != "" { 92 | _, err := vikingCli.Config.GetKeyByName(key) 93 | if err != nil { 94 | return err 95 | } 96 | } 97 | 98 | m := config.Machine{ 99 | Name: name, 100 | Hosts: []config.Host{}, 101 | CreatedAt: time.Now(), 102 | } 103 | 104 | for _, host := range hosts { 105 | user, hostIp, port, err := parseMachine(host, user, port) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | m.Hosts = append(m.Hosts, config.Host{ 111 | IP: hostIp, 112 | Port: port, 113 | User: user, 114 | Key: key, 115 | }) 116 | } 117 | 118 | if err := vikingCli.Config.AddMachine(m); err != nil { 119 | return err 120 | } 121 | 122 | fmt.Fprintf(vikingCli.Out, "Machine %s added.\n", name) 123 | 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /cli/command/machine/copy.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/d3witt/viking/archive" 14 | "github.com/d3witt/viking/cli/command" 15 | "github.com/d3witt/viking/sshexec" 16 | "github.com/schollz/progressbar/v3" 17 | "github.com/urfave/cli/v2" 18 | ) 19 | 20 | func NewCopyCmd(vikingCli *command.Cli) *cli.Command { 21 | return &cli.Command{ 22 | Name: "copy", 23 | Aliases: []string{"cp"}, 24 | Usage: "Copy files/folders between local and remote machine", 25 | Args: true, 26 | ArgsUsage: "MACHINE:SRC_PATH DEST_PATH | SRC_PATH MACHINE:DEST_PATH", 27 | Action: func(ctx *cli.Context) error { 28 | if ctx.NArg() != 2 { 29 | return fmt.Errorf("expected 2 arguments, got %d", ctx.NArg()) 30 | } 31 | 32 | return runCopy(vikingCli, ctx.Args().Get(0), ctx.Args().Get(1)) 33 | }, 34 | } 35 | } 36 | 37 | func parseMachinePath(fullPath string) (machine, path string) { 38 | if strings.Contains(fullPath, ":") { 39 | parts := strings.SplitN(fullPath, ":", 2) 40 | return parts[0], parts[1] 41 | } 42 | 43 | return "", fullPath 44 | } 45 | 46 | func runCopy(vikingCli *command.Cli, from, to string) error { 47 | fromMachine, fromPath := parseMachinePath(from) 48 | toMachine, toPath := parseMachinePath(to) 49 | 50 | if fromMachine == "" && toMachine == "" { 51 | return fmt.Errorf("at least one path must contain machine name") 52 | } 53 | 54 | if fromMachine != "" && toMachine != "" { 55 | return fmt.Errorf("cannot copy between two remote machines") 56 | } 57 | 58 | machine := fromMachine + toMachine 59 | 60 | execs, err := vikingCli.MachineExecuters(machine) 61 | defer func() { 62 | for _, exec := range execs { 63 | exec.Close() 64 | } 65 | }() 66 | 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if fromMachine != "" { 72 | return copyFromRemote(vikingCli, execs, fromPath, toPath) 73 | } 74 | 75 | return copyToRemote(vikingCli, execs, fromPath, toPath) 76 | } 77 | 78 | func copyToRemote(vikingCli *command.Cli, execs []sshexec.Executor, from, to string) error { 79 | data, err := archive.Tar(from) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | // Create a temporary file to store the tar archive 85 | tmpFile, err := os.CreateTemp("", "archive-*.tar") 86 | if err != nil { 87 | return err 88 | } 89 | defer os.Remove(tmpFile.Name()) 90 | 91 | // Write the tar archive to the temporary file 92 | written, err := io.Copy(tmpFile, data) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | // Close the temporary file to flush the data 98 | if err := tmpFile.Close(); err != nil { 99 | return err 100 | } 101 | 102 | var wg sync.WaitGroup 103 | var mu sync.Mutex 104 | var errorMessages []string 105 | 106 | wg.Add(len(execs)) 107 | 108 | bar := copyProgressBar( 109 | vikingCli.Out, 110 | written*int64(len(execs)), 111 | "Sending", 112 | ) 113 | 114 | for _, exec := range execs { 115 | go func(exec sshexec.Executor) { 116 | defer wg.Done() 117 | 118 | // Open the temporary file for reading 119 | tmpFile, err := os.Open(tmpFile.Name()) 120 | if err != nil { 121 | mu.Lock() 122 | errorMessages = append(errorMessages, fmt.Sprintf("Error opening temporary file: %v", err)) 123 | mu.Unlock() 124 | return 125 | } 126 | defer tmpFile.Close() 127 | 128 | // Create a multi-reader to read from the file and update the progress bar 129 | reader := io.TeeReader(tmpFile, bar) 130 | 131 | if err := archive.UntarRemote(exec, to, reader); err != nil { 132 | mu.Lock() 133 | errorMessages = append(errorMessages, fmt.Sprintf("%s: %v", exec.Addr(), err)) 134 | mu.Unlock() 135 | return 136 | } 137 | }(exec) 138 | } 139 | 140 | wg.Wait() 141 | 142 | printCopyStatus(vikingCli.Out, len(execs), errorMessages) 143 | 144 | return nil 145 | } 146 | 147 | func copyFromRemote(vikingCli *command.Cli, execs []sshexec.Executor, from, to string) error { 148 | var wg sync.WaitGroup 149 | var mu sync.Mutex 150 | var errorMessages []string 151 | 152 | wg.Add(len(execs)) 153 | 154 | bar := copyProgressBar( 155 | vikingCli.Out, 156 | -1, 157 | "Receiving", 158 | ) 159 | 160 | for _, exec := range execs { 161 | go func(exec sshexec.Executor) { 162 | defer wg.Done() 163 | 164 | dest := to 165 | if len(execs) > 1 { 166 | dest = path.Join(to, exec.Addr()) 167 | } 168 | 169 | data, err := archive.TarRemote(exec, from) 170 | if err != nil { 171 | mu.Lock() 172 | errorMessages = append(errorMessages, fmt.Sprintf("%s: %v", exec.Addr(), err)) 173 | mu.Unlock() 174 | return 175 | } 176 | 177 | reader := io.TeeReader(data, bar) 178 | 179 | buf := new(bytes.Buffer) 180 | _, err = buf.ReadFrom(reader) 181 | if err != nil { 182 | mu.Lock() 183 | errorMessages = append(errorMessages, fmt.Sprintf("%s: %v", exec.Addr(), err)) 184 | mu.Unlock() 185 | return 186 | } 187 | 188 | if err := archive.Untar(buf, dest); err != nil { 189 | mu.Lock() 190 | errorMessages = append(errorMessages, fmt.Sprintf("Error untar to %s: %v", dest, err)) 191 | mu.Unlock() 192 | return 193 | } 194 | }(exec) 195 | } 196 | 197 | wg.Wait() 198 | bar.Finish() 199 | 200 | printCopyStatus(vikingCli.Out, len(execs), errorMessages) 201 | 202 | return nil 203 | } 204 | 205 | func printCopyStatus(out io.Writer, total int, errorMessages []string) { 206 | errCount := len(errorMessages) 207 | 208 | fmt.Fprintf(out, "Success: %d, Errors: %d\n", total-errCount, errCount) 209 | 210 | if len(errorMessages) > 0 { 211 | fmt.Fprintln(out, "Error details:") 212 | for _, message := range errorMessages { 213 | fmt.Fprintln(out, message) 214 | } 215 | } 216 | } 217 | 218 | func copyProgressBar(out io.Writer, maxBytes int64, message string) *progressbar.ProgressBar { 219 | return progressbar.NewOptions64( 220 | maxBytes, 221 | progressbar.OptionSetDescription(message), 222 | progressbar.OptionSetWriter(out), 223 | progressbar.OptionShowBytes(true), 224 | progressbar.OptionSetWidth(10), 225 | progressbar.OptionThrottle(65*time.Millisecond), 226 | progressbar.OptionShowCount(), 227 | progressbar.OptionClearOnFinish(), 228 | progressbar.OptionSpinnerType(14), 229 | progressbar.OptionFullWidth(), 230 | progressbar.OptionSetRenderBlankState(true), 231 | ) 232 | } 233 | -------------------------------------------------------------------------------- /cli/command/machine/execute.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/d3witt/viking/cli/command" 10 | "github.com/d3witt/viking/sshexec" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func NewExecuteCmd(vikingCli *command.Cli) *cli.Command { 15 | return &cli.Command{ 16 | Name: "exec", 17 | Usage: "Execute shell command on machine", 18 | ArgsUsage: "NAME \"COMMAND\"", 19 | Flags: []cli.Flag{ 20 | &cli.BoolFlag{ 21 | Name: "tty", 22 | Aliases: []string{"t"}, 23 | Usage: "Allocate a pseudo-TTY", 24 | }, 25 | }, 26 | Action: func(ctx *cli.Context) error { 27 | machine := ctx.Args().First() 28 | cmd := strings.Join(ctx.Args().Tail(), " ") 29 | tty := ctx.Bool("tty") 30 | 31 | return runExecute(vikingCli, machine, cmd, tty) 32 | }, 33 | } 34 | } 35 | 36 | func runExecute(vikingCli *command.Cli, machine string, cmd string, tty bool) error { 37 | execs, err := vikingCli.MachineExecuters(machine) 38 | defer func() { 39 | for _, exec := range execs { 40 | exec.Close() 41 | } 42 | }() 43 | 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if tty { 49 | if len(execs) != 1 { 50 | return fmt.Errorf("cannot allocate a pseudo-TTY to multiple hosts") 51 | } 52 | 53 | return executeTTY(vikingCli, execs[0], cmd) 54 | } 55 | 56 | var wg sync.WaitGroup 57 | wg.Add(len(execs)) 58 | 59 | for _, exec := range execs { 60 | go func(exec sshexec.Executor) { 61 | defer wg.Done() 62 | 63 | out := vikingCli.Out 64 | errOut := vikingCli.Err 65 | if len(execs) > 1 { 66 | prefix := fmt.Sprintf("%s: ", exec.Addr()) 67 | out = out.WithPrefix(prefix) 68 | errOut = errOut.WithPrefix(prefix + "error: ") 69 | } 70 | 71 | if err := execute(out, exec, cmd); err != nil { 72 | fmt.Fprintln(errOut, err.Error()) 73 | } 74 | }(exec) 75 | } 76 | 77 | wg.Wait() 78 | return nil 79 | } 80 | 81 | func execute(out io.Writer, exec sshexec.Executor, cmd string) error { 82 | sshCmd := sshexec.Command(exec, cmd) 83 | 84 | output, err := sshCmd.CombinedOutput() 85 | if handleSSHError(err) != nil { 86 | return err 87 | } 88 | 89 | fmt.Fprint(out, string(output)) 90 | return nil 91 | } 92 | 93 | func executeTTY(vikingCli *command.Cli, exec sshexec.Executor, cmd string) error { 94 | sshCmd := sshexec.Command(exec, cmd) 95 | 96 | w, h, err := vikingCli.In.Size() 97 | if err != nil { 98 | return err 99 | } 100 | 101 | if err := vikingCli.Out.MakeRaw(); err != nil { 102 | return err 103 | } 104 | defer vikingCli.Out.Restore() 105 | 106 | err = sshCmd.RunInteractive(vikingCli.In, vikingCli.Out, vikingCli.Err, w, h) 107 | if handleSSHError(err) != nil { 108 | return err 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func handleSSHError(err error) error { 115 | if _, ok := err.(*sshexec.ExitError); ok { 116 | return nil 117 | } 118 | return err 119 | } 120 | -------------------------------------------------------------------------------- /cli/command/machine/list.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "sort" 5 | "strconv" 6 | 7 | "github.com/d3witt/viking/cli/command" 8 | "github.com/dustin/go-humanize" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func NewListCmd(vikingCli *command.Cli) *cli.Command { 13 | return &cli.Command{ 14 | Name: "ls", 15 | Usage: "List machines", 16 | Action: func(ctx *cli.Context) error { 17 | return listMachines(vikingCli) 18 | }, 19 | } 20 | } 21 | 22 | func listMachines(vikingCli *command.Cli) error { 23 | machines := vikingCli.Config.ListMachines() 24 | 25 | sort.Slice(machines, func(i, j int) bool { 26 | return machines[i].CreatedAt.After(machines[j].CreatedAt) 27 | }) 28 | 29 | data := [][]string{} 30 | 31 | for _, machine := range machines { 32 | firstHost := machine.Hosts[0] 33 | data = append(data, []string{ 34 | machine.Name, 35 | firstHost.IP.String(), 36 | strconv.Itoa(firstHost.Port), 37 | firstHost.Key, 38 | humanize.Time(machine.CreatedAt), 39 | }) 40 | 41 | for _, host := range machine.Hosts[1:] { 42 | data = append(data, []string{ 43 | " ", 44 | host.IP.String(), 45 | strconv.Itoa(host.Port), 46 | host.Key, 47 | " ", 48 | }) 49 | } 50 | } 51 | 52 | command.PrintTable(vikingCli.Out, data) 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /cli/command/machine/machine.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "github.com/d3witt/viking/cli/command" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | func NewCmd(vikingCli *command.Cli) *cli.Command { 9 | return &cli.Command{ 10 | Name: "machine", 11 | Usage: "Manage your machines", 12 | Subcommands: []*cli.Command{ 13 | NewAddCmd(vikingCli), 14 | NewListCmd(vikingCli), 15 | NewRmCmd(vikingCli), 16 | NewExecuteCmd(vikingCli), 17 | NewCopyCmd(vikingCli), 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cli/command/machine/remove.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/d3witt/viking/cli/command" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | func NewRmCmd(vikingCli *command.Cli) *cli.Command { 11 | return &cli.Command{ 12 | Name: "rm", 13 | Usage: "Remove a machine", 14 | Args: true, 15 | ArgsUsage: "NAME", 16 | Action: func(ctx *cli.Context) error { 17 | machine := ctx.Args().First() 18 | return runRemove(vikingCli, machine) 19 | }, 20 | } 21 | } 22 | 23 | func runRemove(vikingCli *command.Cli, machine string) error { 24 | if err := vikingCli.Config.RemoveMachine(machine); err != nil { 25 | return err 26 | } 27 | 28 | fmt.Fprintln(vikingCli.Out, "Machine removed from this computer.") 29 | 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /cli/command/name.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | ) 7 | 8 | func GenerateRandomName() string { 9 | letters := "abcdefghijklmnopqrstuvwxyz" 10 | digits := "123456789" 11 | 12 | name := fmt.Sprintf("%c%c%c%c", 13 | letters[rand.Intn(len(letters))], // Random letter 14 | digits[rand.Intn(len(digits))], // Random digit 15 | letters[rand.Intn(len(letters))], // Random letter 16 | digits[rand.Intn(len(digits))]) // Random digit 17 | 18 | hyphenPosition := rand.Intn(len(name)-1) + 1 19 | nameWithHyphen := name[:hyphenPosition] + "-" + name[hyphenPosition:] 20 | 21 | return nameWithHyphen 22 | } 23 | -------------------------------------------------------------------------------- /cli/command/prompter.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | func Prompt(in io.Reader, out io.Writer, prompt, configDefault string) (string, error) { 11 | if configDefault == "" { 12 | fmt.Fprintf(out, "%s: ", prompt) 13 | } else { 14 | fmt.Fprintf(out, "%s (%s): ", prompt, configDefault) 15 | } 16 | 17 | line, _, err := bufio.NewReader(in).ReadLine() 18 | if err != nil { 19 | return "", fmt.Errorf("Error while reading input: %w", err) 20 | } 21 | 22 | return strings.TrimSpace(string(line)), nil 23 | } 24 | 25 | // PromptForConfirmation requests and checks confirmation from the user. 26 | // This will display the provided message followed by ' [y/N] '. If the user 27 | // input 'y' or 'Y' it returns true otherwise false. If no message is provided, 28 | // "Are you sure you want to proceed? [y/N] " will be used instead. 29 | func PromptForConfirmation(in io.Reader, out io.Writer, message string) (bool, error) { 30 | if message == "" { 31 | message = "Are you sure you want to proceed?" 32 | } 33 | message += " [y/N] " 34 | 35 | answer, err := Prompt(in, out, message, "") 36 | if err != nil { 37 | return false, err 38 | } 39 | 40 | return strings.EqualFold(answer, "y"), nil 41 | } 42 | -------------------------------------------------------------------------------- /cli/command/tablePrinter.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "text/tabwriter" 8 | ) 9 | 10 | func PrintTable(output io.Writer, data [][]string) error { 11 | w := tabwriter.NewWriter(output, 0, 0, 3, ' ', tabwriter.TabIndent) 12 | 13 | for _, line := range data { 14 | // Formatting and printing each line to fit the tabulated format 15 | fmt.Fprintln(w, strings.Join(line, "\t")) 16 | } 17 | 18 | return w.Flush() 19 | } 20 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/BurntSushi/toml" 5 | ) 6 | 7 | type Config struct { 8 | Keys map[string]Key 9 | Machines map[string]Machine 10 | Profile Profile 11 | } 12 | 13 | func defaultConfig() Config { 14 | return Config{ 15 | Keys: make(map[string]Key), 16 | Machines: make(map[string]Machine), 17 | } 18 | } 19 | 20 | func (c Config) Save() error { 21 | filename, err := configFile() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | data, err := toml.Marshal(&c) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | return writeConfigFile(filename, data) 32 | } 33 | -------------------------------------------------------------------------------- /config/config_file.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "syscall" 10 | 11 | "github.com/BurntSushi/toml" 12 | ) 13 | 14 | const ( 15 | VIKING_CONFIG_DIR = "VIKING_CONFIG_DIR" 16 | ) 17 | 18 | func ConfigDir() (string, error) { 19 | var path string 20 | if a := os.Getenv(VIKING_CONFIG_DIR); a != "" { 21 | path = a 22 | } else { 23 | b, err := os.UserConfigDir() 24 | if err != nil { 25 | return "", fmt.Errorf("failed to retrieve config dir path: %w", err) 26 | } 27 | 28 | path = filepath.Join(b, "viking") 29 | } 30 | 31 | if !dirExists(path) { 32 | if err := os.MkdirAll(path, 0o755); err != nil { 33 | return "", fmt.Errorf("failed to create config dir: %w", err) 34 | } 35 | } 36 | 37 | return path, nil 38 | } 39 | 40 | func dirExists(path string) bool { 41 | f, err := os.Stat(path) 42 | return err == nil && f.IsDir() 43 | } 44 | 45 | func fileExists(path string) bool { 46 | f, err := os.Stat(path) 47 | return err == nil && !f.IsDir() 48 | } 49 | 50 | func configFile() (string, error) { 51 | path, err := ConfigDir() 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | return filepath.Join(path, defaultProfileName+".toml"), nil 57 | } 58 | 59 | func ParseDefaultConfig() (Config, error) { 60 | path, err := configFile() 61 | if err != nil { 62 | return Config{}, err 63 | } 64 | 65 | return parseConfig(path) 66 | } 67 | 68 | func readConfigFile(filename string) ([]byte, error) { 69 | f, err := os.Open(filename) 70 | if err != nil { 71 | return nil, pathError(err) 72 | } 73 | defer f.Close() 74 | 75 | data, err := io.ReadAll(f) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return data, nil 81 | } 82 | 83 | func writeConfigFile(filename string, data []byte) error { 84 | err := os.MkdirAll(filepath.Dir(filename), 0o771) 85 | if err != nil { 86 | return pathError(err) 87 | } 88 | 89 | cfgFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) // cargo coded from setup 90 | if err != nil { 91 | return err 92 | } 93 | defer cfgFile.Close() 94 | 95 | _, err = cfgFile.Write(data) 96 | return err 97 | } 98 | 99 | func parseConfigFile(filename string) (cfg Config, err error) { 100 | data, err := readConfigFile(filename) 101 | if err != nil { 102 | return cfg, err 103 | } 104 | 105 | _, err = toml.Decode(string(data), &cfg) 106 | return 107 | } 108 | 109 | func parseConfig(filename string) (Config, error) { 110 | cfg, err := parseConfigFile(filename) 111 | if err != nil { 112 | if os.IsNotExist(err) { 113 | return defaultConfig(), nil 114 | } 115 | } 116 | 117 | return cfg, err 118 | } 119 | 120 | func pathError(err error) error { 121 | var pathError *os.PathError 122 | if errors.As(err, &pathError) && errors.Is(pathError.Err, syscall.ENOTDIR) { 123 | if p := findRegularFile(pathError.Path); p != "" { 124 | return fmt.Errorf("remove or rename regular file `%s` (must be a directory)", p) 125 | } 126 | } 127 | return err 128 | } 129 | 130 | func findRegularFile(p string) string { 131 | for { 132 | if s, err := os.Stat(p); err == nil && s.Mode().IsRegular() { 133 | return p 134 | } 135 | newPath := filepath.Dir(p) 136 | if newPath == p || newPath == "/" || newPath == "." { 137 | break 138 | } 139 | p = newPath 140 | } 141 | return "" 142 | } 143 | -------------------------------------------------------------------------------- /config/key.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | type Key struct { 9 | Name string `toml:"-"` 10 | Private string 11 | Public string 12 | Passphrase string 13 | CreatedAt time.Time 14 | } 15 | 16 | var ( 17 | ErrKeyNameRequired = errors.New("key name is required") 18 | ErrKeyNotFound = errors.New("key not found") 19 | ErrKeyExist = errors.New("key already exists") 20 | ) 21 | 22 | func (c *Config) ListKeys() []Key { 23 | keys := make([]Key, 0, len(c.Keys)) 24 | 25 | for name, key := range c.Keys { 26 | key.Name = name 27 | keys = append(keys, key) 28 | } 29 | 30 | return keys 31 | } 32 | 33 | func (c *Config) AddKey(key Key) error { 34 | _, err := c.GetKeyByName(key.Name) 35 | if err == nil { 36 | return ErrKeyExist 37 | } 38 | 39 | c.Keys[key.Name] = key 40 | 41 | return c.Save() 42 | } 43 | 44 | func (c *Config) RemoveKey(name string) error { 45 | _, err := c.GetKeyByName(name) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | delete(c.Keys, name) 51 | 52 | return c.Save() 53 | } 54 | 55 | func (c *Config) GetKeyByName(name string) (Key, error) { 56 | if name == "" { 57 | return Key{}, ErrKeyNameRequired 58 | } 59 | 60 | if key, ok := c.Keys[name]; ok { 61 | key.Name = name 62 | return key, nil 63 | } 64 | 65 | return Key{}, ErrKeyNotFound 66 | } 67 | -------------------------------------------------------------------------------- /config/machine.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "time" 7 | ) 8 | 9 | type Machine struct { 10 | Name string `toml:"-"` 11 | Hosts []Host 12 | CreatedAt time.Time 13 | } 14 | 15 | type Host struct { 16 | IP net.IP 17 | Port int 18 | User string 19 | Key string 20 | } 21 | 22 | var ( 23 | ErrMachineNotFound = errors.New("machine not found") 24 | ErrMachineAlreadyExists = errors.New("machine already exists") 25 | ErrMachineNameOrHostRequired = errors.New("machine name or host is required") 26 | ) 27 | 28 | func (c *Config) ListMachines() []Machine { 29 | machines := make([]Machine, 0, len(c.Machines)) 30 | 31 | for name, machine := range c.Machines { 32 | machine.Name = name 33 | machines = append(machines, machine) 34 | } 35 | 36 | return machines 37 | } 38 | 39 | func (c *Config) GetMachineByName(name string) (Machine, error) { 40 | if machine, ok := c.Machines[name]; ok { 41 | machine.Name = name 42 | return machine, nil 43 | } 44 | 45 | return Machine{}, ErrMachineNotFound 46 | } 47 | 48 | func (c *Config) AddMachine(machine Machine) error { 49 | _, err := c.GetMachineByName(machine.Name) 50 | if err == nil { 51 | return ErrMachineAlreadyExists 52 | } 53 | 54 | c.Machines[machine.Name] = machine 55 | 56 | return c.Save() 57 | } 58 | 59 | // RemoveMachine removes a machine from the config by name or host. 60 | func (c *Config) RemoveMachine(machine string) error { 61 | m, err := c.GetMachineByName(machine) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | delete(c.Machines, m.Name) 67 | 68 | return c.Save() 69 | } 70 | -------------------------------------------------------------------------------- /config/profile.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Profile struct { 4 | Email string 5 | } 6 | 7 | const defaultProfileName = "default" 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/d3witt/viking 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | golang.org/x/crypto v0.26.0 7 | golang.org/x/term v0.23.0 8 | ) 9 | 10 | require ( 11 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 12 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 13 | github.com/rivo/uniseg v0.4.7 // indirect 14 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 15 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect 16 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect 17 | golang.org/x/image v0.6.0 // indirect 18 | golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect 19 | ) 20 | 21 | require ( 22 | github.com/BurntSushi/toml v1.4.0 23 | github.com/dustin/go-humanize v1.0.1 24 | github.com/schollz/progressbar/v3 v3.14.6 25 | github.com/urfave/cli/v2 v2.27.2 26 | golang.design/x/clipboard v0.7.0 27 | golang.org/x/sys v0.23.0 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 9 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 10 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= 11 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 12 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 13 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 16 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 17 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 18 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 19 | github.com/schollz/progressbar/v3 v3.14.6 h1:GyjwcWBAf+GFDMLziwerKvpuS7ZF+mNTAXIB2aspiZs= 20 | github.com/schollz/progressbar/v3 v3.14.6/go.mod h1:Nrzpuw3Nl0srLY0VlTvC4V6RL50pcEymjy6qyJAaLa0= 21 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 22 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 23 | github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= 24 | github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= 25 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= 26 | github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= 27 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 28 | golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo= 29 | golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E= 30 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 31 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 32 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 33 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 34 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 35 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= 36 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= 37 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 38 | golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4= 39 | golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= 40 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 41 | golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c h1:Gk61ECugwEHL6IiyyNLXNzmu8XslmRP2dS0xjIYhbb4= 42 | golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY= 43 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 44 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 45 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 46 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 47 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 48 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 49 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 50 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 51 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 52 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 54 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 55 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 56 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 57 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 58 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 64 | golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= 65 | golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 66 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 67 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 68 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 69 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 70 | golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= 71 | golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= 72 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 73 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 74 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 75 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 76 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 77 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 78 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 79 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 80 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 81 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 82 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 83 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/d3witt/viking/cli/command" 10 | "github.com/d3witt/viking/cli/command/cfg" 11 | "github.com/d3witt/viking/cli/command/key" 12 | "github.com/d3witt/viking/cli/command/machine" 13 | "github.com/d3witt/viking/config" 14 | "github.com/d3witt/viking/streams" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | var version = "dev" // set by build script 19 | 20 | func main() { 21 | c, err := config.ParseDefaultConfig() 22 | if err != nil { 23 | fmt.Println(err.Error()) 24 | return 25 | } 26 | 27 | cmdLogger := slog.New(command.NewCmdLogHandler(os.Stdout, &slog.HandlerOptions{ 28 | Level: slog.LevelInfo, 29 | })) 30 | 31 | vikingCli := &command.Cli{ 32 | Config: &c, 33 | In: streams.StdIn, 34 | Out: streams.StdOut, 35 | Err: streams.StdErr, 36 | CmdLogger: cmdLogger, 37 | } 38 | 39 | app := &cli.App{ 40 | Name: "viking", 41 | Usage: "Manage your SSH keys and remote machines", 42 | Version: version, 43 | Commands: []*cli.Command{ 44 | // Often used commands 45 | machine.NewExecuteCmd(vikingCli), 46 | machine.NewCopyCmd(vikingCli), 47 | 48 | // Other commands 49 | key.NewCmd(vikingCli), 50 | machine.NewCmd(vikingCli), 51 | cfg.NewConfigCmd(vikingCli), 52 | }, 53 | Suggest: true, 54 | Reader: vikingCli.In, 55 | Writer: vikingCli.Out, 56 | ErrWriter: vikingCli.Err, 57 | ExitErrHandler: func(ctx *cli.Context, err error) { 58 | if err != nil { 59 | fmt.Fprintf(vikingCli.Err, "Error: %v\n", err) 60 | os.Exit(0) 61 | } 62 | }, 63 | } 64 | 65 | if err := app.Run(os.Args); err != nil { 66 | log.Fatal(err) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /sshexec/client.go: -------------------------------------------------------------------------------- 1 | package sshexec 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "strconv" 8 | "time" 9 | 10 | "golang.org/x/crypto/ssh" 11 | "golang.org/x/crypto/ssh/agent" 12 | ) 13 | 14 | func SshClient(host string, port int, user, private, passphrase string) (*ssh.Client, error) { 15 | var sshAuth ssh.AuthMethod 16 | var err error 17 | 18 | if private != "" { 19 | sshAuth, err = authorizeWithKey(private, passphrase) 20 | } else { 21 | sshAuth, err = authorizeWithSSHAgent() 22 | } 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | // Set up SSH client configuration 28 | config := &ssh.ClientConfig{ 29 | User: user, 30 | Auth: []ssh.AuthMethod{ 31 | sshAuth, 32 | }, 33 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 34 | Timeout: time.Second * 5, 35 | } 36 | 37 | addr := net.JoinHostPort(host, strconv.Itoa(port)) 38 | 39 | return ssh.Dial("tcp", addr, config) 40 | } 41 | 42 | func authorizeWithKey(key, passphrase string) (ssh.AuthMethod, error) { 43 | var signer ssh.Signer 44 | var err error 45 | 46 | if passphrase != "" { 47 | signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(passphrase)) 48 | } else { 49 | signer, err = ssh.ParsePrivateKey([]byte(key)) 50 | } 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return ssh.PublicKeys(signer), nil 56 | } 57 | 58 | func authorizeWithSSHAgent() (ssh.AuthMethod, error) { 59 | conn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to connect to ssh-agent: %w", err) 62 | } 63 | defer conn.Close() 64 | 65 | sshAgent := agent.NewClient(conn) 66 | return ssh.PublicKeysCallback(sshAgent.Signers), nil 67 | } 68 | -------------------------------------------------------------------------------- /sshexec/cmd.go: -------------------------------------------------------------------------------- 1 | package sshexec 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "sync" 10 | ) 11 | 12 | type Cmd struct { 13 | Executor 14 | 15 | Name string 16 | Args []string 17 | Stdin io.Reader 18 | Stdout, Stderr io.Writer 19 | } 20 | 21 | func Command(exec Executor, name string, args ...string) *Cmd { 22 | return &Cmd{ 23 | Executor: exec, 24 | Name: name, 25 | Args: args, 26 | } 27 | } 28 | 29 | func (c *Cmd) Start() error { 30 | return c.Executor.Start(c.argv(), c.Stdin, c.Stdout, c.Stderr) 31 | } 32 | 33 | func (c *Cmd) Run() error { 34 | var b bytes.Buffer 35 | 36 | if c.Stderr == nil { 37 | c.Stderr = &b 38 | } 39 | 40 | if err := c.Start(); err != nil { 41 | return err 42 | } 43 | 44 | if err := c.Wait(); err != nil { 45 | return fmt.Errorf("%w.\n%s", err, b.String()) 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (c *Cmd) RunInteractive(in io.Reader, out, stderr io.Writer, w, h int) error { 52 | if c.Stderr == nil { 53 | c.Stderr = stderr 54 | } 55 | 56 | if err := c.StartInteractive(c.argv(), in, out, stderr, w, h); err != nil { 57 | return err 58 | } 59 | 60 | if err := c.Wait(); err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (c *Cmd) Output() (string, error) { 68 | if c.Stdout != nil { 69 | return "", errors.New("stdout already set") 70 | } 71 | 72 | var b bytes.Buffer 73 | c.Stdout = &b 74 | err := c.Run() 75 | return b.String(), err 76 | } 77 | 78 | type singleWriter struct { 79 | b bytes.Buffer 80 | mu sync.Mutex 81 | } 82 | 83 | func (w *singleWriter) Write(p []byte) (int, error) { 84 | w.mu.Lock() 85 | defer w.mu.Unlock() 86 | return w.b.Write(p) 87 | } 88 | 89 | func (c *Cmd) CombinedOutput() (string, error) { 90 | if c.Stdout != nil { 91 | return "", errors.New("stdout already set") 92 | } 93 | if c.Stderr != nil { 94 | return "", errors.New("stderr already set") 95 | } 96 | 97 | var b singleWriter 98 | c.Stdout = &b 99 | c.Stderr = &b 100 | err := c.Run() 101 | 102 | return b.b.String(), err 103 | } 104 | 105 | func (c *Cmd) argv() string { 106 | return strings.Join(append([]string{c.Name}, c.Args...), " ") 107 | } 108 | 109 | func (c *Cmd) String() string { 110 | return c.argv() 111 | } 112 | 113 | type ExitError struct { 114 | Content string 115 | Status int 116 | } 117 | 118 | func (e ExitError) Error() string { 119 | if e.Content != "" { 120 | return e.Content 121 | } 122 | 123 | return fmt.Sprintf("exited with status %v", e.Status) 124 | } 125 | -------------------------------------------------------------------------------- /sshexec/executor.go: -------------------------------------------------------------------------------- 1 | package sshexec 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | 9 | "golang.org/x/crypto/ssh" 10 | ) 11 | 12 | type Executor interface { 13 | Start(cmd string, in io.Reader, out, stderr io.Writer) error 14 | StartInteractive(cmd string, in io.Reader, out, stderr io.Writer, w, h int) error 15 | Wait() error 16 | Close() error 17 | Addr() string 18 | SetLogger(logger *slog.Logger) 19 | } 20 | 21 | // executor allows for the execution of multiple commands, but only one at a time. It is not safe for concurrent use. 22 | type executor struct { 23 | host string 24 | port int 25 | user string 26 | private string 27 | passphrase string 28 | 29 | logger *slog.Logger 30 | 31 | session *ssh.Session 32 | client *ssh.Client 33 | } 34 | 35 | func NewExecutor(host string, port int, user, private, passphrase string) Executor { 36 | return &executor{ 37 | host: host, 38 | port: port, 39 | user: user, 40 | private: private, 41 | passphrase: passphrase, 42 | } 43 | } 44 | 45 | func (e *executor) Addr() string { 46 | return e.host 47 | } 48 | 49 | func (e *executor) Start(cmd string, in io.Reader, out, stderr io.Writer) error { 50 | return e.startSession(cmd, in, out, stderr, nil) 51 | } 52 | 53 | func (e *executor) StartInteractive(cmd string, in io.Reader, out, stderr io.Writer, w, h int) error { 54 | modes := ssh.TerminalModes{ 55 | ssh.ECHO: 1, 56 | ssh.TTY_OP_ISPEED: 14400, 57 | ssh.TTY_OP_OSPEED: 14400, 58 | } 59 | return e.startSession(cmd, in, out, stderr, &ptyOptions{h, w, modes}) 60 | } 61 | 62 | type ptyOptions struct { 63 | h, w int 64 | modes ssh.TerminalModes 65 | } 66 | 67 | func (e *executor) startSession(cmd string, in io.Reader, out, outErr io.Writer, pty *ptyOptions) error { 68 | if e.session != nil { 69 | return errors.New("another command is currently running") 70 | } 71 | 72 | if e.client == nil { 73 | client, err := SshClient(e.host, e.port, e.user, e.private, e.passphrase) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | e.client = client 79 | } 80 | 81 | session, err := e.client.NewSession() 82 | if err != nil { 83 | return fmt.Errorf("failed to create SSH session: %w", err) 84 | } 85 | 86 | session.Stdin = in 87 | session.Stdout = out 88 | session.Stderr = outErr 89 | 90 | if pty != nil { 91 | if err := session.RequestPty("xterm-256color", pty.h, pty.w, pty.modes); err != nil { 92 | _ = session.Close() 93 | return err 94 | } 95 | } 96 | 97 | e.session = session 98 | 99 | if e.logger != nil { 100 | e.logger.Info("starting command", "host", e.host, "cmd", cmd) 101 | } 102 | 103 | if err := session.Start(cmd); err != nil { 104 | _ = e.closeSession() 105 | return fmt.Errorf("failed to start ssh session: %w", err) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func (e *executor) Wait() error { 112 | if e.session == nil { 113 | return errors.New("failed to wait command: command not started") 114 | } 115 | defer e.closeSession() 116 | 117 | if err := e.session.Wait(); err != nil { 118 | if exitErr, ok := err.(*ssh.ExitError); ok { 119 | return &ExitError{ 120 | Status: exitErr.ExitStatus(), 121 | Content: exitErr.String(), 122 | } 123 | } 124 | 125 | return fmt.Errorf("failed to wait SSH session: %w", err) 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (e *executor) SetLogger(logger *slog.Logger) { 132 | e.logger = logger 133 | } 134 | 135 | func (e *executor) Close() error { 136 | if err := e.closeSession(); err != nil { 137 | return err 138 | } 139 | 140 | if e.client != nil { 141 | return e.client.Close() 142 | } 143 | 144 | return nil 145 | } 146 | 147 | func (e *executor) closeSession() error { 148 | if e.session != nil { 149 | if err := e.session.Close(); err != nil { 150 | if err != io.EOF { 151 | if e.logger != nil { 152 | e.logger.Error("failed to close SSH session", "host", e.host, "err", err) 153 | } 154 | 155 | return err 156 | } 157 | } 158 | 159 | e.session = nil 160 | } 161 | 162 | return nil 163 | } 164 | -------------------------------------------------------------------------------- /streams/in.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "io" 5 | "os" 6 | ) 7 | 8 | type In struct { 9 | stream 10 | in io.ReadCloser 11 | } 12 | 13 | var StdIn = NewIn(os.Stdin, int(os.Stdin.Fd())) 14 | 15 | func NewIn(in io.ReadCloser, fd int) *In { 16 | i := new(In) 17 | i.fd = fd 18 | i.in = in 19 | 20 | return i 21 | } 22 | 23 | func (i *In) Read(p []byte) (n int, err error) { 24 | return i.in.Read(p) 25 | } 26 | 27 | func (i *In) Close() error { 28 | return i.in.Close() 29 | } 30 | -------------------------------------------------------------------------------- /streams/out.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "sync" 7 | ) 8 | 9 | type Out struct { 10 | stream 11 | 12 | out io.Writer 13 | outMu *sync.Mutex 14 | prefix string 15 | } 16 | 17 | func NewOut(out io.Writer) *Out { 18 | return &Out{ 19 | out: out, 20 | outMu: &sync.Mutex{}, 21 | } 22 | } 23 | 24 | func (o *Out) Write(p []byte) (n int, err error) { 25 | o.outMu.Lock() 26 | defer o.outMu.Unlock() 27 | 28 | if o.prefix != "" { 29 | prefixedData := append([]byte(o.prefix), p...) 30 | return o.out.Write(prefixedData) 31 | } 32 | return o.out.Write(p) 33 | } 34 | 35 | func (o *Out) SetOutput(out io.Writer) { 36 | o.outMu.Lock() 37 | defer o.outMu.Unlock() 38 | o.out = out 39 | } 40 | 41 | func (o *Out) WithPrefix(prefix string) *Out { 42 | return &Out{ 43 | stream: o.stream, 44 | out: o.out, 45 | outMu: o.outMu, 46 | prefix: o.prefix + prefix, 47 | } 48 | } 49 | 50 | var ( 51 | // StdOut is the standard output stream. 52 | StdOut = NewOut(os.Stdout) 53 | // StdErr is the standard error stream. 54 | StdErr = NewOut(os.Stderr) 55 | ) 56 | -------------------------------------------------------------------------------- /streams/stream.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import "golang.org/x/term" 4 | 5 | type stream struct { 6 | fd int 7 | state *term.State 8 | } 9 | 10 | func (s *stream) IsTerminal() bool { 11 | return term.IsTerminal(s.fd) 12 | } 13 | 14 | func (s *stream) MakeRaw() error { 15 | state, err := term.MakeRaw(s.fd) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | s.state = state 21 | return nil 22 | } 23 | 24 | func (s *stream) Restore() error { 25 | if s.state == nil { 26 | return nil 27 | } 28 | 29 | err := term.Restore(s.fd, s.state) 30 | s.state = nil 31 | return err 32 | } 33 | 34 | func (s *stream) Size() (width int, height int, err error) { 35 | return term.GetSize(s.fd) 36 | } 37 | --------------------------------------------------------------------------------