#はじめに
のっぴきならない事情で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には対応していないとのことでした。
今回作成したものは必要最低限の機能しかないので、これらには及ばないと思いますが、シンプルなものになっていると思っています。
#インストール
go get -u "github.com/jlandowner/go-interactive-ssh"
#実装サンプル
Exampleに記載しているものです。
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
を渡す様にしました。
func NewClient(sshconfig *ssh.ClientConfig, host string, port string, prompts []Prompt) *Client
最後の引数で、Prompt構造体なるものを渡す様にしました。
これは、SSHログインしたシェルの中で、コマンド実行が完了したか確認するために利用します。
type Prompt struct {
SufixPattern byte
SufixPosition int
}
通常はpi@raspberrypi:~ $
の様なプロンプトだと思うので、$と#のプロンプトは事前定義しておきました。
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関数に実行したいコマンドのリストを渡すだけです。
func (c *Client) Run(ctx context.Context, cmds []*Command) error
コマンドは、期待する結果、コールバック関数などを含むCommand構造体として渡します。
// 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()
で取得します。
func NewCommand(input string, options ...Option) *Command
Input以外はWith...
から始まるOptionインターフェースを実装した関数で設定してください。
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相当の機能については、はじめは正規表現で受け取る方式にしようかと思いましたが、
自由度を持たせるために、コールバック関数を受け取る様にしました。
// WithCallbackOption is option function called after command is finished
func WithCallbackOption(v func(c *Command) (bool, error)) *withCallback
コールバック関数内では、コマンド実行の標準出力結果を持ったCommand構造体を引数として受け取るので、コマンド実行結果から自分で好きな処理を記載することができます。
コマンド実行結果はc.Result
で参照できます。
type Command struct {
...
Result *CommandResult
}
type CommandResult struct {
Output []string
Lines int
ReturnCode int
}
コールバック関数の実装例はREADME.mdにも記載していますが、いくつかコマンドのプリセットをcommands.go
に作成していて、それ自体がコールバックを利用しています。
例えばCheckUser()
はこの様な形です。
// 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
を返した場合のみ実行できるコマンドを仕込める様にしました。
// WithNextCommandOption is option function called after Callback func return true
func WithNextCommandOption(v func(c *Command) *Command) *withNextCommand
これは、「のっぴきならない事情の件」で、su
を行う必要があり、追加した機能です。
sudo
でもパスワード入力が必要な場合も、同じ様にできると思います。
// 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も大歓迎です。