読者です 読者をやめる 読者になる 読者になる

GeekFactory

int128.hatenablog.com

SSH接続のコードをApache MINA sshdでテストする

groovy spock

Apache MINA sshd(以下sshd)を使うとJavaVMだけでSSHサーバを実行できます。あらかじめ定義されているインタフェースを実装するだけで、認証やコマンド実行を受け付けることができます。

本稿では、SSH接続のコードをテストする場合にサーバサイドにsshdを使う方法を説明します。OpenSSHの環境を用意しなくてもSSHサーバを使ったテストが簡単に実行できます。ここではテストコードはGroovy + Spockで書いています。

準備編

sshdのライブラリをテストクラスパスに追加します。Gradleなら下記の依存関係を追加します。

dependencies {
    testCompile 'org.apache.sshd:sshd-core:0.9.0'
}

次に、テストヘルパーを用意します。

具体的には、SSHサーバを起動するヘルパーメソッドを用意します。ローカルの空きポートを見つけてListenするようにしておきます。また、起動するたびにホスト鍵が変わらないようにホスト鍵ファイルを用意してリソースに入れておきます。

import org.apache.sshd.SshServer
import org.apache.sshd.common.keyprovider.FileKeyPairProvider

class SshServerMock {
    static SshServer setUpLocalhostServer() {
        SshServer.setUpDefaultServer().with {
            host = 'localhost'
            port = pickUpFreePort()
            keyPairProvider = new FileKeyPairProvider(SshServerMock.getResource('/hostkey').file)
            it
        }
    }

    static int pickUpFreePort() {
        def socket = new ServerSocket(0)
        def port = socket.localPort
        socket.close()
        port
    }
}

それではテストコードを書いていきましょう。

テストケースごとにSSHサーバを起動するようにsetupとteardownを書きます。

class SomethingSpec extends Specification {
    SshServer server

    def setup() {
        server = SshServerMock.setUpLocalhostServer()
    }

    def teardown() {
        server.stop(true)
    }
}

ここでは、テスト対象のSSHクライアントはconnectメソッドやexecuteCommandメソッドを持っていると想定しています。実際のテスト対象に合わせて適宜読み替えてください。

認証のテスト

sshdはパスワード、公開鍵、GSSの認証方式に対応しています*1。デフォルトではすべての認証が拒否されるため、認証したい方式のAuthenticatorを設定しておく必要があります。

パスワード認証に対応するにはPasswordAuthenticatorを実装します。Spockを使う場合はMockを設定するだけでOKです。楽チン。

    def "password authentication"() {
        given:
        server.passwordAuthenticator = Mock(PasswordAuthenticator)
        server.start()

        def client = /* テスト対象のSSHクライアント */

        when:
        client.connect(server.host, server.port, 'someuser', 'somepassword')

        then:
        1 * server.passwordAuthenticator.authenticate('someuser', 'somepassword', _) >> true
    }

公開鍵認証に対応するにはPublickeyAuthenticatorを実装します。

    def "public key authentication"() {
        given:
        server.publickeyAuthenticator = Mock(PublickeyAuthenticator)
        server.start()

        def client = /* テスト対象のSSHクライアント */

        when:
        client.connect(server.host, server.port, 'someuser', /* 秘密鍵 */)

        then:
        1 * server.publickeyAuthenticator.authenticate('someuser', { PublicKey k -> k.algorithm == 'RSA' } as PublicKey, _) >> true
    }

GSS認証はテストで使ったことないので分かりません。

Spockを使わない場合は、認証方式に対応するインタフェースを実装してauthenticateメソッドでtrueを返すようにします。

コマンド実行のテスト

sshdでコマンド実行を受け付けるには、CommandFactoryを実装したオブジェクトをcommandFactoryプロパティに設定します。コマンド実行の処理はCommandの実装クラスで行います。

ややこしいので、具体的な流れを下記に書きます。

  1. sshdがクライアントからコマンド実行のリクエストを受け取ったとします。
  2. sshdはCommandFactory#createCommand(String)を実行します。createCommandメソッドはCommandを実装したオブジェクトを返すように実装しておきます。
  3. sshdはCommand#setInputStream(InputStream)を実行し、標準入力のストリームを渡します。標準出力や標準エラーも同じ。
  4. sshdはCommand#setExitCallback(ExitCallback)を実行します。ここで渡されるExitCallbackは後でコマンドの終了ステータスを設定するために使います。
  5. sshdはCommand#start()を実行します。
  6. startメソッドの中で好きな処理を行います。
  7. startメソッドでExitCallback#onExit(int)を実行します。ここで渡した値が終了ステータスになります。
  8. sshdはクライアントに終了ステータスを返して、チャネルを閉じます。

CommandFactoryやCommandはインタフェースなので、標準入力などを受け取るメソッドも実装しなければなりません。これは面倒なので、Commandを実装したオブジェクトを返すヘルパーメソッドを用意しておきます。

class SshServerMock {
    static class CommandContext {
        InputStream inputStream
        OutputStream outputStream
        OutputStream errorStream
        ExitCallback exitCallback
        Environment environment
    }

    static command(Closure interaction) {
        def context = new CommandContext()
        [setInputStream: { InputStream inputStream -> context.inputStream = inputStream },
         setOutputStream: { OutputStream outputStream -> context.outputStream = outputStream },
         setErrorStream: { OutputStream errorStream -> context.errorStream = errorStream },
         setExitCallback: { ExitCallback callback -> context.exitCallback = callback },
         start: { Environment env ->
            context.environment = env
            interaction.call(context)
         },
         destroy: { -> } ] as Command
    }
}

それではテストコードを書いていきます。

まず、commandFactoryプロパティにCommandFactoryを実装したオブジェクトを設定します。Spockを使う場合はMockでOKです。 CommandFactory#createCommand(String)の引数には、クライアントから受け取ったコマンドラインが渡されます。期待通りのコマンドラインを受け取っているかどうかはthen節で検証します。

    def "execute a command"() {
        given:
        def recorder = Mock(Closure)
        server.commandFactory = Mock(CommandFactory) {
            createCommand(_) >> { String commandLine ->
                SshServerMock.command { CommandContext c ->
                    recorder(commandLine)
                    c.exitCallback.onExit(0)
                }
            }
        }
        server.passwordAuthenticator = Mock(PasswordAuthenticator)
        server.start()

        def client = /* テスト対象のSSHクライアント */

        when:
        client.connect(server.host, server.port, 'someuser', 'somepassword')
        client.executeCommand('ls -l')

        then:
        1 * server.passwordAuthenticator.authenticate('someuser', 'somepassword', _) >> true
        then:
        1 * recorder('ls -l')
    }

Commandの実装ではExitCallback#onExit(int)の実行を忘れないようにしてください。これがないとsshdが待ち状態になり、テストが終了しなくなります。

コマンド実行も意外と簡単ですね。

まとめ

SSHクライアントのプロダクトコードをテストするにはApache MINA sshdが便利です。テストでなく、実用的なSSHサーバを立てたいという場合にも有用です。モックライブラリと組み合わせることで、少ないコードで素早くSSHサーバを実装できます。

*1:UserAuthを実装すれば他の認証方式にも対応できそうですが未確認