Goで対話式SSHクライアントパッケージを作成した

#はじめに

のっぴきならない事情でWindowsからLinuxホストに対話式にSSH接続するプログラムが必要になり、Goで対話式SSHするためのクライアントパッケージを作成しました。

go-interactive-ssh
https://github.com/jlandowner/go-interactive-ssh

#Google と Netflix の go-expect

「Go Expect」などで検索するとGoogleとNetflixのリポジトリが見つかります。

google/goexpect
https://github.com/google/goexpect

Netflix/go-expect
https://github.com/Netflix/go-expect

が、共にWindowsには対応していないとのことでした。
今回作成したものは必要最低限の機能しかないので、これらには及ばないと思いますが、シンプルなものになっていると思っています。

#インストール
```bash
go get -u "github.com/jlandowner/go-interactive-ssh"
```

#実装サンプル

[Example](https://github.com/jlandowner/go-interactive-ssh/blob/master/example/main.go)に記載しているものです。

```go:main.go
package main

import (
	"context"
	"log"

	"golang.org/x/crypto/ssh"

	issh "github.com/jlandowner/go-interactive-ssh"
)

func main() {
	ctx := context.Background()

	config := &ssh.ClientConfig{
		User:            "pi",
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
		Auth: []ssh.AuthMethod{
			ssh.Password("raspberry"),
		},
	}

	// create client
	client := issh.NewClient(config, "raspberrypi.local", "22", []issh.Prompt{issh.DefaultPrompt})

	// give Commands to client and Run
	err := client.Run(ctx, commands())
	if err != nil {
		log.Fatal(err)
	}
	log.Println("OK")
}

// make Command structs executed sequentially in remote host.  
func commands() []*issh.Command {
	return []*issh.Command{
		issh.CheckUser("pi"),
		issh.NewCommand("pwd", issh.WithOutputLevelOption(issh.Output)),
		issh.ChangeDirectory("/tmp"),
		issh.NewCommand("ls -l", issh.WithOutputLevelOption(issh.Output)),
	}
}
```

#サンプル・仕様説明

### セットアップ
SSHクライアントの設定は、標準のSSHと同じにしたく、`ssh.ClientConfig`を渡す様にしました。

```go
func NewClient(sshconfig *ssh.ClientConfig, host string, port string, prompts []Prompt) *Client 
```

最後の引数で、Prompt構造体なるものを渡す様にしました。
これは、SSHログインしたシェルの中で、コマンド実行が完了したか確認するために利用します。

```go:prompt.go
type Prompt struct {
	SufixPattern  byte
	SufixPosition int
}
```

通常は`pi@raspberrypi:~ $ `の様なプロンプトだと思うので、$と#のプロンプトは事前定義しておきました。

```go:prompt.go
var (
	// DefaultPrompt is prompt pettern like "pi@raspberrypi:~ $ "
	DefaultPrompt = Prompt{
		SufixPattern:  '$',
		SufixPosition: 2,
	}
	// DefaultRootPrompt is prompt pettern like "pi@raspberrypi:~ $ "
	DefaultRootPrompt = Prompt{
		SufixPattern:  '#',
		SufixPosition: 2,
	}
)

```

このPromptに一致するまで待機します。

### 実行とCommand構造体
あとはこのclientのRun関数に実行したいコマンドのリストを渡すだけです。

```go
func (c *Client) Run(ctx context.Context, cmds []*Command) error 
```
コマンドは、期待する結果、コールバック関数などを含むCommand構造体として渡します。

```go:command.go
// Command has Input config and Output in remote host.
// Input is line of command execute in remote host.
// Callback is called after input command is finished. You can check whether Output is exepected in this function.
// NextCommand is called after Callback and called only Callback returns "true". NextCommand cannot has another NextCommand.
// ReturnCodeCheck is "true", Input is added ";echo $?" and check after Output is 0. Also you can manage retrun code in Callback.
// OutputLevel is logging level of command. Secret command should be set Silent
// Result is Command Output. You can use this in Callback, NextCommand, DefaultNextCommand functions.
type Command struct {
	Input           string
	Callback        func(c *Command) (bool, error)
	NextCommand     func(c *Command) *Command
	ReturnCodeCheck bool
	OutputLevel     OutputLevel
	Timeout         time.Duration
	Result          *CommandResult
}
```

Command構造体は`NewCommand()`で取得します。

```go:command.go
func NewCommand(input string, options ...Option) *Command
```

Input以外は`With...`から始まるOptionインターフェースを実装した関数で設定してください。


```go:option.go
func WithNoCheckReturnCodeOption() *withNoCheckReturnCode
func WithOutputLevelOption(v OutputLevel) *withOutputLevel 
func WithTimeoutOption(v time.Duration) *withTimeout
func WithCallbackOption(v func(c *Command) (bool, error)) *withCallback
func WithNextCommandOption(v func(c *Command) *Command) *withNextCommand
```

通常は全てのInputに渡されたコマンドに`;echo $?`を付与して実行し、リターンコードチェックをおこないます。
標準入力がある場合など、リターンコードチェックをしたくない場合は、`WithNoCheckReturnCodeOption()`を付与してください。

### Expect
Expect相当の機能については、はじめは正規表現で受け取る方式にしようかと思いましたが、
自由度を持たせるために、コールバック関数を受け取る様にしました。

```go:option.go
// WithCallbackOption is option function called after command is finished
func WithCallbackOption(v func(c *Command) (bool, error)) *withCallback
```
コールバック関数内では、コマンド実行の標準出力結果を持ったCommand構造体を引数として受け取るので、コマンド実行結果から自分で好きな処理を記載することができます。

コマンド実行結果は`c.Result`で参照できます。

```go:command.go
type Command struct {
	...
	Result          *CommandResult
}

type CommandResult struct {
	Output     []string
	Lines      int
	ReturnCode int
}
```

コールバック関数の実装例は[README.md](https://github.com/jlandowner/go-interactive-ssh)にも記載していますが、いくつかコマンドのプリセットを`commands.go`に作成していて、それ自体がコールバックを利用しています。

例えば`CheckUser()`はこの様な形です。

```go:commands.go
// CheckUser check current login user is expected in remote host
func CheckUser(expectUser string) *Command {
	whoami := "whoami"
	callback := func(c *Command) (bool, error) {
		if c.Result.Lines-3 < 0 {
			return false, errors.New("user is not expected")
		}
		user := c.Result.Output[c.Result.Lines-3]
		if user != expectUser {
			return false, fmt.Errorf("user is invalid expected %v got %v", expectUser, user)
		}
		return true, nil
	}
	return NewCommand(whoami, WithCallbackOption(callback), WithOutputLevelOption(Output))
}
```

またコールバック関数で`true`を返した場合のみ実行できるコマンドを仕込める様にしました。

```go:option.go
// WithNextCommandOption is option function called after Callback func return true
func WithNextCommandOption(v func(c *Command) *Command) *withNextCommand
```

これは、「のっぴきならない事情の件」で、`su`を行う必要があり、追加した機能です。
`sudo`でもパスワード入力が必要な場合も、同じ様にできると思います。

```go:commands.go
// SwitchUser run "su - xxx" command in remote host
func SwitchUser(user, password string, newUserPrompt Prompt) *Command {
	su := "su - " + user
	...
	nextCommand := func(c *Command) *Command {
		nextcallback := func(c *Command) (bool, error) {
			...
			return true, nil
		}
		return NewCommand(password, WithCallbackOption(nextcallback), // passwordを入力するCommand構造体を返す
			WithNoCheckReturnCodeOption(), WithOutputLevelOption(Silent))
	}

	return NewCommand(su, WithCallbackOption(callback),
		WithNextCommandOption(nextCommand), WithNoCheckReturnCodeOption())
}
```

#最後に
これを実装したタスクなどをgoroutineで実行すれば、同時に複数ホストに対する操作を自動化できます。
`Run`は`context`を受け取る様にしているので、キャンセル動作なども簡単に実現できます。

よろしければお使いください。IssueやPullRequestも大歓迎です。