├── .gitignore ├── LICENSE ├── README.md ├── client.go ├── client_test.go ├── command.go ├── commands.go ├── example └── main.go ├── option.go └── prompt.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 jlandowner 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 | [![GoReportCard](https://goreportcard.com/badge/github.com/jlandowner/go-interactive-ssh)](https://goreportcard.com/report/github.com/jlandowner/go-interactive-ssh) 2 | 3 | # go-interactive-ssh 4 | 5 | Go interactive ssh client. 6 | 7 | It makes it possible to run commands in remote shell and "Expect" each command's behaviors like checking output, handling errors and so on. 8 | 9 | Support use-case is ssh access from Windows, MacOS or Linux as client and access to Linux as a remote host. 10 | 11 | ## Install 12 | 13 | ```bash 14 | go get -u "github.com/jlandowner/go-interactive-ssh" 15 | ``` 16 | 17 | ## Example 18 | 19 | ```go:example/main.go 20 | package main 21 | 22 | import ( 23 | "context" 24 | "log" 25 | 26 | "golang.org/x/crypto/ssh" 27 | 28 | issh "github.com/jlandowner/go-interactive-ssh" 29 | ) 30 | 31 | func main() { 32 | ctx := context.Background() 33 | 34 | config := &ssh.ClientConfig{ 35 | User: "pi", 36 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 37 | Auth: []ssh.AuthMethod{ 38 | ssh.Password("raspberry"), 39 | }, 40 | } 41 | 42 | // create client 43 | client := issh.NewClient(config, "raspberrypi.local", "22", []issh.Prompt{issh.DefaultPrompt}) 44 | 45 | // give Commands to client and Run 46 | err := client.Run(ctx, commands()) 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | log.Println("OK") 51 | } 52 | 53 | // make Command structs executed sequentially in remote host. 54 | func commands() []*issh.Command { 55 | return []*issh.Command{ 56 | issh.CheckUser("pi"), 57 | issh.NewCommand("pwd", issh.WithOutputLevelOption(issh.Output)), 58 | issh.ChangeDirectory("/tmp"), 59 | issh.NewCommand("ls -l", issh.WithOutputLevelOption(issh.Output)), 60 | } 61 | } 62 | ``` 63 | 64 | ## Usage 65 | 66 | ### Setup Client 67 | 68 | Setup Client by `NewClient()`. 69 | 70 | ```go:client.go 71 | func NewClient(sshconfig *ssh.ClientConfig, host string, port string, prompts []Prompt) *Client 72 | ``` 73 | SSH client settings is the same as standard `ssh.ClientConfig`. 74 | 75 | The last argument is `[]Prompt`, which is a list of `Prompt` struct. 76 | 77 | `Prompt` is used to confirm whether command execution is completed in the SSH login shell. 78 | 79 | ```go:prompt.go 80 | type Prompt struct { 81 | SufixPattern byte 82 | SufixPosition int 83 | } 84 | ``` 85 | 86 | Normally, a prompt is like `pi@raspberrypi: ~ $`, so '$' and '#' prompts are predefined in the package. 87 | 88 | Client wait until each command outputs match this. 89 | 90 | ```go: prompt.go 91 | var ( 92 | // DefaultPrompt is prompt pettern like "pi @ raspberrypi: ~ $" 93 | DefaultPrompt = Prompt { 94 | SufixPattern: '$', 95 | SufixPosition: 2, 96 | } 97 | // DefaultRootPrompt is prompt pettern like "pi @ raspberrypi: ~ $" 98 | DefaultRootPrompt = Prompt { 99 | SufixPattern: '#', 100 | SufixPosition: 2, 101 | } 102 | ) 103 | ``` 104 | 105 | ### Run by Command struct 106 | 107 | All you have to do is just `Run()` with a list of commands you want to execute in a remote host. 108 | 109 | ```go:client.go 110 | func (c *Client) Run(ctx context.Context, cmds []*Command) error 111 | ``` 112 | 113 | The command is passed as a `Command` struct that contains some options, a callback function and also its results. 114 | 115 | ```go:command.go 116 | // Command has Input config and Output in remote host. 117 | // Input is line of command execute in remote host. 118 | // Callback is called after input command is finished. You can check whether Output is exepected in this function. 119 | // NextCommand is called after Callback and called only Callback returns "true". NextCommand cannot has another NextCommand. 120 | // ReturnCodeCheck is "true", Input is added ";echo $?" and check after Output is 0. Also you can manage retrun code in Callback. 121 | // OutputLevel is logging level of command. Secret command should be set Silent 122 | // Result is Command Output. You can use this in Callback, NextCommand, DefaultNextCommand functions. 123 | type Command struct { 124 | Input string 125 | Callback func(c *Command) (bool, error) 126 | NextCommand func(c *Command) *Command 127 | ReturnCodeCheck bool 128 | OutputLevel OutputLevel 129 | Timeout time.Duration 130 | Result *CommandResult 131 | } 132 | ``` 133 | 134 | You can generate a `Command` struct by `NewCommand()` 135 | 136 | ```go: command.go 137 | func NewCommand(input string, options ...Option) *Command 138 | ``` 139 | 140 | In addition to `Input` which is a command sent to remote shell, it can be set some options with a function that implements the `Option` interface named `WithXXXOption()`. 141 | 142 | 143 | ```go: option.go 144 | func WithNoCheckReturnCodeOption() *withNoCheckReturnCode 145 | func WithOutputLevelOption(v OutputLevel) *withOutputLevel 146 | func WithTimeoutOption(v time.Duration) *withTimeout 147 | func WithCallbackOption(v func (c *Command) (bool, error)) *withCallback 148 | func WithNextCommandOption(v func (c *Command) * Command) *withNextCommand 149 | ``` 150 | 151 | By default, all `Input` is added "; echo $?" and the return code is checked. 152 | If you do not want to check the return code, such as when command has standard inputs, add `WithNoCheckReturnCodeOption()` option to `NewCommand()`. 153 | 154 | ```go 155 | cmd := issh.NewCommand("ls -l") // input is "ls -l; echo $?" and checked if return code is 0 156 | cmd := issh.NewCommand("ls -l", WithNoCheckReturnCodeOption()) // input is just "ls -l" 157 | ``` 158 | 159 | ## Expect 160 | 161 | As a feature like "Expect", you can use standard outputs in `Callback` function. 162 | 163 | You can describe a function executing after each commands (like checking output or handling errors executing), as `Command` struct that contains stdout is given by callback's argument. 164 | 165 | Set `Callback` function by a option of `NewCommand()` 166 | 167 | ```go:option.go 168 | // WithCallbackOption is option function called after command is finished 169 | func WithCallbackOption(v func(c *Command) (bool, error)) *withCallback 170 | ``` 171 | 172 | You can refer to the command execution result with `c.Result` in callback. 173 | 174 | ```go:command.go 175 | type Command struct { 176 | ... 177 | Result *CommandResult 178 | } 179 | 180 | type CommandResult struct { 181 | Output []string 182 | Lines int 183 | ReturnCode int 184 | } 185 | ``` 186 | 187 | Plus, you can add more command that can be executed only when the callback function returns `true`. 188 | 189 | ```go:option.go 190 | // WithNextCommandOption is option function called after Callback func return true 191 | func WithNextCommandOption(v func(c *Command) *Command) *withNextCommand 192 | ``` 193 | 194 | The summary is as follows. 195 | 196 | - __WithCallbackOption(v func(c *Command) (bool, error))__ 197 | - Augument "c" is previous Command pointer. You can get command output in stdout by c.Result.Output. 198 | - Returned bool is whether run WithNextCommandOption function or not. 199 | - If err is not nil, issh.Run will exit. 200 | (Example see [CheckUser](https://github.com/jlandowner/go-interactive-ssh/blob/master/commands.go#L64)) 201 | 202 | - __WithNextCommandOption(v v func(c *Command) *Command)__ 203 | - Augument "c" is previous Command pointer. 204 | - Returned Command is executed after previous Command in remote host. 205 | - It's useful when you have to type sequentially in stdin. (Example see [SwitchUser](https://github.com/jlandowner/go-interactive-ssh/blob/master/commands.go#L28)) 206 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package issh 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net" 10 | "sync" 11 | 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | // Client has configuration to interact a host 16 | type Client struct { 17 | Sshconfig *ssh.ClientConfig 18 | Host string 19 | Port string 20 | Prompt []Prompt 21 | } 22 | 23 | // NewClient return new go-interactive-ssh client 24 | // prompts []Prompt is list of all expected prompt pettern. 25 | // for example if you use normal user and switch to root user, '$' and '#' prompts must be given. 26 | func NewClient(sshconfig *ssh.ClientConfig, host string, port string, prompts []Prompt) *Client { 27 | return &Client{ 28 | Sshconfig: sshconfig, 29 | Host: host, 30 | Port: port, 31 | Prompt: prompts, 32 | } 33 | } 34 | 35 | // Run execute given commands in remote host 36 | func (c *Client) Run(ctx context.Context, cmds []*Command) error { 37 | url := net.JoinHostPort(c.Host, c.Port) 38 | client, err := ssh.Dial("tcp", url, c.Sshconfig) 39 | if err != nil { 40 | return fmt.Errorf("error in ssh.Dial to %v %w", url, err) 41 | } 42 | 43 | defer client.Close() 44 | session, err := client.NewSession() 45 | 46 | if err != nil { 47 | return fmt.Errorf("error in client.NewSession to %v %w", url, err) 48 | } 49 | defer session.Close() 50 | 51 | modes := ssh.TerminalModes{ 52 | ssh.ECHO: 0, // disable echoing 53 | ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud 54 | ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud 55 | } 56 | 57 | if err := session.RequestPty("xterm", 80, 40, modes); err != nil { 58 | return fmt.Errorf("error in session.RequestPty to %v %w", url, err) 59 | } 60 | 61 | w, err := session.StdinPipe() 62 | if err != nil { 63 | return fmt.Errorf("error in session.StdinPipe to %v %w", url, err) 64 | } 65 | r, err := session.StdoutPipe() 66 | if err != nil { 67 | return fmt.Errorf("error in session.StdoutPipe to %v %w", url, err) 68 | } 69 | in, out := listener(w, r, c.Prompt) 70 | if err := session.Start("/bin/sh"); err != nil { 71 | return fmt.Errorf("error in session.Start to %v %w", url, err) 72 | } 73 | 74 | <-out // ignore login output 75 | for _, cmd := range cmds { 76 | select { 77 | case <-ctx.Done(): 78 | return errors.New("canceled by context") 79 | 80 | default: 81 | logf(cmd.OutputLevel, "[%v]: cmd [%v] starting...", c.Host, cmd.Input) 82 | 83 | in <- cmd 84 | err := cmd.wait(ctx, out) 85 | if err != nil { 86 | return fmt.Errorf("[%v]: Error in cmd [%v] at waiting %w", c.Host, cmd.Input, err) 87 | } 88 | 89 | if outputs, ok := cmd.output(); ok { 90 | for _, output := range outputs { 91 | fmt.Println(output) 92 | } 93 | } 94 | 95 | doNext, err := cmd.Callback(cmd) 96 | if err != nil { 97 | return fmt.Errorf("[%v]: Error in cmd [%v] Callback %w", c.Host, cmd.Input, err) 98 | } 99 | 100 | if doNext && cmd.NextCommand != nil { 101 | nextCmd := cmd.NextCommand(cmd) 102 | 103 | logf(nextCmd.OutputLevel, "[%v]: next cmd [%v] starting...", c.Host, nextCmd.Input) 104 | 105 | in <- nextCmd 106 | err = nextCmd.wait(ctx, out) 107 | if err != nil { 108 | return fmt.Errorf("[%v]: Error in next cmd [%v] at waiting %w", c.Host, cmd.Input, err) 109 | } 110 | 111 | if outputs, ok := nextCmd.output(); ok { 112 | for _, output := range outputs { 113 | fmt.Println(output) 114 | } 115 | } 116 | 117 | _, err := nextCmd.Callback(nextCmd) 118 | if err != nil { 119 | return fmt.Errorf("[%v]: Error in next cmd [%v] Callback %w", c.Host, nextCmd.Input, err) 120 | } 121 | 122 | logf(nextCmd.OutputLevel, "[%v]: next cmd [%v] done", c.Host, nextCmd.Input) 123 | 124 | } 125 | 126 | logf(cmd.OutputLevel, "[%v]: cmd [%v] done", c.Host, cmd.Input) 127 | } 128 | } 129 | session.Close() 130 | 131 | return nil 132 | } 133 | 134 | func listener(w io.Writer, r io.Reader, prompts []Prompt) (chan<- *Command, <-chan string) { 135 | in := make(chan *Command, 1) 136 | out := make(chan string, 1) 137 | var wg sync.WaitGroup 138 | wg.Add(1) //for the shell itself 139 | go func() { 140 | for cmd := range in { 141 | wg.Add(1) 142 | w.Write([]byte(cmd.Input + "\n")) 143 | wg.Wait() 144 | } 145 | }() 146 | go func() { 147 | var ( 148 | buf [65 * 1024]byte 149 | t int 150 | ) 151 | for { 152 | n, err := r.Read(buf[t:]) 153 | if err != nil { 154 | close(in) 155 | close(out) 156 | return 157 | } 158 | t += n 159 | if t < 2 { 160 | continue 161 | } 162 | if buf[t-1] == ':' { 163 | out <- string(buf[:t]) 164 | t = 0 165 | wg.Done() 166 | continue 167 | } 168 | 169 | for _, p := range prompts { 170 | // fmt.Print(string(p.SufixPattern)) 171 | if buf[t-p.SufixPosition] == p.SufixPattern { 172 | out <- string(buf[:t]) 173 | t = 0 174 | wg.Done() 175 | break 176 | } 177 | } 178 | } 179 | }() 180 | return in, out 181 | } 182 | 183 | func logf(level OutputLevel, msg string, v ...interface{}) { 184 | format := "go-interactive-ssh: " + msg 185 | if level != Silent { 186 | log.Printf(format, v...) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package issh 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | func TestRun(t *testing.T) { 15 | h := os.Getenv("SSH_HOST_IP") 16 | u := os.Getenv("SSH_LOGIN_USER") 17 | p := os.Getenv("SSH_LOGIN_PASS") 18 | if h == "" { 19 | h = "raspberrypi.local" 20 | } 21 | if u == "" { 22 | u = "pi" 23 | } 24 | if p == "" { 25 | p = "raspberry" 26 | } 27 | 28 | ctx := context.Background() 29 | 30 | config := &ssh.ClientConfig{ 31 | User: u, 32 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 33 | Auth: []ssh.AuthMethod{ 34 | ssh.Password(p), 35 | }, 36 | } 37 | 38 | Client := NewClient(config, h, "22", []Prompt{DefaultPrompt, DefaultRootPrompt}) 39 | 40 | err := Client.Run(ctx, testSwitchUser()) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | t.Log("OK") 45 | } 46 | 47 | func testSwitchUser() []*Command { 48 | files := []string{"go-interactive-ssh-test-1.txt", "go-interactive-ssh-test-2.txt", "go-interactive-ssh-test-3.txt"} 49 | 50 | var createFiles []*Command 51 | for _, file := range files { 52 | createFiles = append(createFiles, NewCommand("touch "+file)) 53 | } 54 | 55 | filetest := NewCommand("ls -l go-interactive-ssh-test*.txt", WithCallbackOption(func(c *Command) (bool, error) { 56 | if c.Result.ReturnCode != 0 { 57 | fmt.Println("/tmp/go-interactive-ssh-test*.txt not found") 58 | return false, nil 59 | } 60 | var file string 61 | for _, output := range c.Result.Output { 62 | row := strings.Fields(output) 63 | if len(row) == 9 { 64 | file = row[8] 65 | fmt.Println(file) 66 | } 67 | } 68 | return true, nil 69 | 70 | }), WithOutputLevelOption(Output)) 71 | 72 | var commands []*Command 73 | commands = append(commands, 74 | CheckUser("pi"), 75 | SwitchUser("dummy", "dummy", DefaultRootPrompt), //TODO change user to switch 76 | NewCommand("id", WithOutputLevelOption(Output)), 77 | ChangeDirectory("/tmp")) 78 | 79 | commands = append(commands, createFiles...) 80 | 81 | commands = append(commands, 82 | filetest, 83 | NewCommand("sleep 5", WithTimeoutOption(time.Second*10))) 84 | 85 | commands = append(commands, cleanup(files)...) 86 | commands = append(commands, 87 | Exit(), 88 | CheckUser("pi"), 89 | ) 90 | 91 | return commands 92 | } 93 | 94 | func cleanup(files []string) []*Command { 95 | var deleteFiles []*Command 96 | for _, file := range files { 97 | f := file 98 | rm := NewCommand("ls -l "+f, 99 | WithCallbackOption(func(c *Command) (bool, error) { 100 | if c.Result.ReturnCode != 0 { 101 | fmt.Printf("%v not found\n", file) 102 | return false, nil 103 | } 104 | return true, nil 105 | }), WithNextCommandOption(func(c *Command) *Command { 106 | return NewCommand("rm -f " + f) 107 | })) 108 | deleteFiles = append(deleteFiles, rm) 109 | } 110 | return deleteFiles 111 | } 112 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package issh 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const ( 13 | // DefaultTimeOut archeve, Command is canneled. You can change by WithTimeoutOption 14 | DefaultTimeOut = time.Second * 5 15 | ) 16 | 17 | var ( 18 | // DefaultCallback is called after Command and just sleep in a second. You can change by WithCallbackOption 19 | DefaultCallback = func(c *Command) (bool, error) { 20 | time.Sleep(time.Millisecond * 500) 21 | if c.Result.ReturnCode != 0 { 22 | return false, fmt.Errorf("cmd [%v] %v", c.Input, ErrReturnCodeNotZero) 23 | } 24 | return true, nil 25 | } 26 | // ErrReturnCodeNotZero is error in command exit with non zero 27 | ErrReturnCodeNotZero = errors.New("return code is not 0") 28 | ) 29 | 30 | // Command has Input config and Output in remote host. 31 | // Input is line of command execute in remote host. 32 | // Callback is called after input command is finished. You can check whether Output is exepected in this function. 33 | // NextCommand is called after Callback and called only Callback returns "true". NextCommand cannot has another NextCommand. 34 | // ReturnCodeCheck is "true", Input is added ";echo $?" and check after Output is 0. Also you can manage retrun code in Callback. 35 | // OutputLevel is logging level of command. Secret command should be set Silent 36 | // Result is Command Output. You can use this in Callback, NextCommand, DefaultNextCommand functions. 37 | type Command struct { 38 | Input string 39 | Callback func(c *Command) (bool, error) 40 | NextCommand func(c *Command) *Command 41 | ReturnCodeCheck bool 42 | OutputLevel OutputLevel 43 | Timeout time.Duration 44 | Result *CommandResult 45 | } 46 | 47 | // CommandResult has command output and return code in remote host 48 | type CommandResult struct { 49 | Output []string 50 | Lines int 51 | ReturnCode int 52 | } 53 | 54 | // OutputLevel set logging level of command 55 | type OutputLevel int 56 | 57 | const ( 58 | // Silent logs nothing 59 | Silent OutputLevel = iota 60 | // Info logs only start and end of command 61 | Info 62 | // Output logs command output in remote host 63 | Output 64 | ) 65 | 66 | // NewCommand return Command with given options 67 | func NewCommand(input string, options ...Option) *Command { 68 | c := &Command{ 69 | Input: input, 70 | OutputLevel: Info, 71 | Timeout: DefaultTimeOut, 72 | Callback: DefaultCallback, 73 | ReturnCodeCheck: true, 74 | Result: &CommandResult{}, 75 | } 76 | 77 | for _, opt := range options { 78 | opt.Apply(c) 79 | } 80 | 81 | if c.ReturnCodeCheck { 82 | c.Input += ";echo $?" 83 | } 84 | return c 85 | } 86 | 87 | func (c *Command) wait(ctx context.Context, out <-chan string) error { 88 | timeout, cancel := context.WithTimeout(ctx, c.Timeout) 89 | defer cancel() 90 | 91 | for { 92 | select { 93 | case v := <-out: 94 | c.Result.Output = strings.Split(v, "\r\n") 95 | c.Result.Lines = len(c.Result.Output) 96 | 97 | if c.ReturnCodeCheck { 98 | if c.Result.Lines-2 < 0 { 99 | return fmt.Errorf("Couldn't check return code lines not enough %v", c.Result.Lines) 100 | } 101 | 102 | returnCode, err := strconv.Atoi(c.Result.Output[c.Result.Lines-2]) 103 | if err != nil { 104 | return fmt.Errorf("Couldn't check retrun code %v", err) 105 | } 106 | 107 | c.Result.ReturnCode = returnCode 108 | } 109 | return nil 110 | case <-timeout.Done(): 111 | if c.OutputLevel == Silent { 112 | return errors.New("canceled by timeout or by parent") 113 | } 114 | return fmt.Errorf("[%v] is canceled by timeout or by parent", c.Input) 115 | } 116 | } 117 | } 118 | 119 | func (c *Command) output() ([]string, bool) { 120 | if c.OutputLevel != Output { 121 | return nil, false 122 | } 123 | var output []string 124 | for i := 0; i < c.Result.Lines-1; i++ { 125 | if c.Result.Output[i] == "0" { 126 | break 127 | } 128 | output = append(output, c.Result.Output[i]) 129 | } 130 | return output, true 131 | } 132 | -------------------------------------------------------------------------------- /commands.go: -------------------------------------------------------------------------------- 1 | package issh 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // ChangeDirectory run "cd" command in remote host 11 | func ChangeDirectory(tgtdir string) *Command { 12 | cd := fmt.Sprintf("cd %v;pwd", tgtdir) 13 | 14 | callback := func(c *Command) (bool, error) { 15 | time.Sleep(time.Second) 16 | // fmt.Println(c.Result.Output[c.Result.Lines-2]) 17 | if c.Result.Output[c.Result.Lines-2] != tgtdir { 18 | return false, fmt.Errorf("wrong output in cd expect %v got %v", tgtdir, c.Result.Output[c.Result.Lines-2]) 19 | } 20 | return true, nil 21 | } 22 | c := NewCommand(cd, WithCallbackOption(callback), 23 | WithNoCheckReturnCodeOption(), WithOutputLevelOption(Output)) 24 | return c 25 | } 26 | 27 | // SwitchUser run "su - xxx" command in remote host 28 | func SwitchUser(user, password string, newUserPrompt Prompt) *Command { 29 | su := "su - " + user 30 | 31 | callback := func(c *Command) (bool, error) { 32 | time.Sleep(time.Second) 33 | expects := []string{"パスワード:", "Password:"} //TODO support "Password:" or other prompt pattern 34 | got := c.Result.Output[c.Result.Lines-1] 35 | 36 | for _, expect := range expects { 37 | if strings.TrimRight(got, " ") == expect { 38 | return true, nil 39 | } 40 | } 41 | return false, fmt.Errorf("wrong output in su expect %v got %v", expects, got) 42 | } 43 | 44 | nextCommand := func(c *Command) *Command { 45 | nextcallback := func(c *Command) (bool, error) { 46 | time.Sleep(time.Second * 1) 47 | got := c.Result.Output[c.Result.Lines-1] 48 | if got[len(got)-newUserPrompt.SufixPosition] != newUserPrompt.SufixPattern { 49 | fmt.Println(got) 50 | return false, fmt.Errorf("wrong output in su expect %v(%v) got %v(%v) RootPassword may be invalid", 51 | string(newUserPrompt.SufixPattern), newUserPrompt.SufixPattern, string(got[len(got)-2]), got[len(got)-2]) 52 | } 53 | return true, nil 54 | } 55 | return NewCommand(password, WithCallbackOption(nextcallback), 56 | WithNoCheckReturnCodeOption(), WithOutputLevelOption(Silent)) 57 | } 58 | 59 | return NewCommand(su, WithCallbackOption(callback), 60 | WithNextCommandOption(nextCommand), WithNoCheckReturnCodeOption()) 61 | } 62 | 63 | // CheckUser check current login user is expected in remote host 64 | func CheckUser(expectUser string) *Command { 65 | whoami := "whoami" 66 | callback := func(c *Command) (bool, error) { 67 | if c.Result.Lines-3 < 0 { 68 | return false, errors.New("user is not expected") 69 | } 70 | user := c.Result.Output[c.Result.Lines-3] 71 | if user != expectUser { 72 | return false, fmt.Errorf("user is invalid expected %v got %v", expectUser, user) 73 | } 74 | return true, nil 75 | } 76 | return NewCommand(whoami, WithCallbackOption(callback), WithOutputLevelOption(Output)) 77 | } 78 | 79 | // Exit run "exit" command in remote host 80 | func Exit() *Command { 81 | return NewCommand("exit", WithNoCheckReturnCodeOption()) 82 | } 83 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "golang.org/x/crypto/ssh" 8 | 9 | issh "github.com/jlandowner/go-interactive-ssh" 10 | ) 11 | 12 | func main() { 13 | ctx := context.Background() 14 | 15 | config := &ssh.ClientConfig{ 16 | User: "pi", 17 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 18 | Auth: []ssh.AuthMethod{ 19 | ssh.Password("raspberry"), 20 | }, 21 | } 22 | 23 | client := issh.NewClient(config, "raspberrypi.local", "22", []issh.Prompt{issh.DefaultPrompt}) 24 | 25 | err := client.Run(ctx, commands()) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | log.Println("OK") 30 | } 31 | 32 | func commands() []*issh.Command { 33 | return []*issh.Command{ 34 | issh.CheckUser("pi"), 35 | issh.ChangeDirectory("/tmp"), 36 | issh.NewCommand("sleep 2"), 37 | issh.NewCommand("ls -l", issh.WithOutputLevelOption(issh.Output)), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package issh 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Option is Command struct option 8 | type Option interface { 9 | Apply(*Command) 10 | } 11 | 12 | // NoCheckReturnCodeOption is option whether add ";echo $?" and check return code after command 13 | func WithNoCheckReturnCodeOption() *withNoCheckReturnCode { 14 | opt := withNoCheckReturnCode(false) 15 | return &opt 16 | } 17 | 18 | type withNoCheckReturnCode bool 19 | 20 | func (w *withNoCheckReturnCode) Apply(c *Command) { 21 | c.ReturnCodeCheck = false 22 | } 23 | 24 | // WithOutputLevelOption is option of command log print 25 | func WithOutputLevelOption(v OutputLevel) *withOutputLevel { 26 | opt := withOutputLevel(v) 27 | return &opt 28 | } 29 | 30 | type withOutputLevel OutputLevel 31 | 32 | func (w *withOutputLevel) Apply(c *Command) { 33 | c.OutputLevel = OutputLevel(*w) 34 | } 35 | 36 | // WithTimeoutOption is option time.Duration to command timeout 37 | func WithTimeoutOption(v time.Duration) *withTimeout { 38 | opt := withTimeout(v) 39 | return &opt 40 | } 41 | 42 | type withTimeout time.Duration 43 | 44 | func (w *withTimeout) Apply(c *Command) { 45 | c.Timeout = time.Duration(*w) 46 | } 47 | 48 | // WithCallbackOption is option function called after command is finished 49 | func WithCallbackOption(v func(c *Command) (bool, error)) *withCallback { 50 | opt := withCallback(v) 51 | return &opt 52 | } 53 | 54 | type withCallback func(c *Command) (bool, error) 55 | 56 | func (w *withCallback) Apply(c *Command) { 57 | c.Callback = *w 58 | } 59 | 60 | // WithNextCommandOption is option function called after Callback func return true 61 | func WithNextCommandOption(v func(c *Command) *Command) *withNextCommand { 62 | opt := withNextCommand(v) 63 | return &opt 64 | } 65 | 66 | type withNextCommand func(c *Command) *Command 67 | 68 | func (w *withNextCommand) Apply(c *Command) { 69 | c.NextCommand = *w 70 | } 71 | -------------------------------------------------------------------------------- /prompt.go: -------------------------------------------------------------------------------- 1 | package issh 2 | 3 | // Prompt represent ssh prompt pettern used in check whether command is finished. 4 | // Example: 5 | // Terminal Prompt "pi@raspberrypi:~ $ " 6 | // SufixPattern='$' (rune to byte) 7 | // SufixPosition=2 ($ + space) 8 | // 9 | // When you use multi prompt such as Root user (often '#'), you must give all prompt pattern before Run 10 | type Prompt struct { 11 | SufixPattern byte 12 | SufixPosition int 13 | } 14 | 15 | var ( 16 | // DefaultPrompt is prompt pettern like "pi@raspberrypi:~ $ " 17 | DefaultPrompt = Prompt{ 18 | SufixPattern: '$', 19 | SufixPosition: 2, 20 | } 21 | // DefaultRootPrompt is prompt pettern like "# " 22 | DefaultRootPrompt = Prompt{ 23 | SufixPattern: '#', 24 | SufixPosition: 2, 25 | } 26 | ) 27 | --------------------------------------------------------------------------------