Lazy Diary @ Hatena Blog

PowerShell / Java / miscellaneous things about software development, Tips & Gochas. CC BY-SA 4.0/Apache License 2.0

Java用SSHクライアントライブラリの比較

背景

JavaによるOLTP処理の中で、「別サーバのauthorized_keysに指定されたコマンドをパスフレーズなしのSSH鍵でキックし、その戻り値を確認する」という処理を行いたい場合、外部コマンドとしてSSHを起動するのはいかにも手間がかかる。

そこで、Javaで実装されたSSHクライアントライブラリについて、上記のような使いかたができるか確認した。

比較対象としては、Mavenで利用プロジェクトの多いSSHクライアントライブラリ上位2つであるJSchApache Mina SSHD、およびこれらに依存しない独自の実装としてJenkinsでメンテナンスされているTrilead SSH(旧Ganymed SSH2)を取りあげた。ちなみに、他のライブラリとしてはsshjApache jcloudsのSSHクライアントがある。ただ、sshjのバックエンドはApache Mina SSHDのようだし、jcloudsはsshjを使ったバックエンドJSchを使ったバックエンドからの選択式のようなので、結局最終的には今回確認したライブラリのどれかが動くという話になりそう。

比較に使用したコード

package com.example.demo;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.concurrent.TimeUnit;

import org.apache.sshd.client.SshClient;
import org.apache.sshd.client.channel.ClientChannel;
import org.apache.sshd.client.channel.ClientChannelEvent;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.channel.Channel;
import org.apache.sshd.common.config.keys.FilePasswordProvider;
import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;

import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.trilead.ssh2.ChannelCondition;
import com.trilead.ssh2.Connection;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        try (ConfigurableApplicationContext ctx = SpringApplication.run(DemoApplication.class, args)) {
            DemoApplication a = ctx.getBean(DemoApplication.class);
            a.run();
        }
    }

    public void run() {
        int retVal;
        try {
            // retVal = listFolderStructureJsch("username", "172.xx.xx.xx", 22);
            // retVal = listFolderStructureMina("username", "172.xx.xx.xx", 22, 1);
            retVal = listFolderStructureTrileadSSH("username", "172.xx.xx.xx", 22);

            System.out.println(retVal);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public int listFolderStructureJsch(String username, String host, int port) throws Exception {
        Session session = null;
        ChannelExec channel = null;
        JSch jsch = null;

        try {
            jsch = new JSch();
            jsch.addIdentity("/path/to/.ssh/id_rsa");

            session = jsch.getSession(username, host, port);
            session.setConfig("StrictHostKeyChecking", "no");
            session.connect();

            channel = (ChannelExec) session.openChannel("exec");
            ByteArrayOutputStream responseStream = new ByteArrayOutputStream();
            channel.setOutputStream(responseStream);
            channel.connect();

            while (channel.isConnected()) {
                Thread.sleep(100);
            }

            String responseString = new String(responseStream.toByteArray());
            System.out.println(responseString);
        } finally {
            if (session != null) {
                session.disconnect();
            }
            if (channel != null) {
                channel.disconnect();
            }
        }
        return channel.getExitStatus();
    }

    public Integer listFolderStructureMina(String username, String host, int port, long defaultTimeoutSeconds)
            throws IOException {

        SshClient client = SshClient.setUpDefaultClient();
        client.start();
        Integer exitStatus = null;

        try (ClientSession session = client.connect(username, host, port)
                .verify(defaultTimeoutSeconds, TimeUnit.SECONDS).getSession()) {

            Path privateKeyPath = FileSystems.getDefault().getPath("/path/to/.ssh/id_rsa");
            FileKeyPairProvider provider = new FileKeyPairProvider(privateKeyPath);
            provider.setPasswordFinder(FilePasswordProvider.of(""));
            session.setKeyIdentityProvider(provider);
            session.auth().verify(defaultTimeoutSeconds, TimeUnit.SECONDS);

            try (ByteArrayOutputStream responseStream = new ByteArrayOutputStream();
                    ClientChannel channel = session.createChannel(Channel.CHANNEL_SHELL)) {
                channel.setOut(responseStream);
                try {
                    channel.open().verify(defaultTimeoutSeconds, TimeUnit.SECONDS);
                    try (OutputStream pipedIn = channel.getInvertedIn()) {
                        pipedIn.flush();
                    }

                    channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED),
                            TimeUnit.SECONDS.toMillis(defaultTimeoutSeconds));
                    String responseString = new String(responseStream.toByteArray());
                    System.out.println(responseString);
                } finally {
                    channel.close(false);
                    exitStatus = channel.getExitStatus();
                }
            }
        } finally {
            client.stop();
        }

        return exitStatus;
    }


    public Integer listFolderStructureTrileadSSH(String username, String host, int port) throws Exception {
        com.trilead.ssh2.Connection sshConnection = new Connection(host, port);
        com.trilead.ssh2.Session sshSession = null;
        try {
          sshConnection.connect();
          if (sshConnection.authenticateWithPublicKey(username, new File("/path/to/.ssh/id_rsa"), "")) {
              sshSession = sshConnection.openSession();
              sshSession.execCommand("");
              InputStream stdout = sshSession.getStdout();
              InputStream stderr = sshSession.getStderr();

              byte[] buffer = new byte[8192];
              StringBuffer sbStdoutResult = new StringBuffer();
              StringBuffer sbStdErrResult = new StringBuffer();

              int currentReadBytes = 0;
              while (true) {
                  if ((stdout.available() == 0) && (stderr.available() == 0)) {
                      int conditions = sshSession.waitForCondition(ChannelCondition.STDOUT_DATA
                              | ChannelCondition.STDERR_DATA | ChannelCondition.EOF | ChannelCondition.EXIT_STATUS,
                              120000);

                      if ((conditions & ChannelCondition.EXIT_STATUS) != 0) {
                          if ((conditions & (ChannelCondition.STDOUT_DATA | ChannelCondition.STDERR_DATA)) == 0) {
                              break;
                          }
                      }

                      if ((conditions & ChannelCondition.EOF) != 0) {
                          if ((conditions & (ChannelCondition.STDOUT_DATA | ChannelCondition.STDERR_DATA)) == 0) {
                              break;
                          }
                      }
                  }

                  while (stdout.available() > 0) {
                      currentReadBytes = stdout.read(buffer);
                      sbStdoutResult.append(new String(buffer, 0, currentReadBytes));
                  }

                  while (stderr.available() > 0) {
                      currentReadBytes = stderr.read(buffer);
                      sbStdErrResult.append(new String(buffer, 0, currentReadBytes));
                  }
              }
              System.out.println(sbStdoutResult);
              System.out.println(sbStdErrResult);

              for(int i = 0 ; i<10 ; i++ ) {
                  Integer status = sshSession.getExitStatus();
                  if( status != null ) {
                      return sshSession.getExitStatus();
                  }
                  Thread.sleep(100);
              }
          }
          return null;
        } finally {
            if (sshSession != null) {
                sshSession.close();
            }
        }
    }
}

比較結果

# 確認観点 JSch Apache Mina SSHD Trilead SSH
1 最新バージョン 0.1.55 2.8.0 build-217-jenkins-215.v0314546d4656
2 最新リリース日 2018/10 2021/12 2022/4
3 パスフレーズなしの鍵でコマンドが実行できること YES YES YES
4 起動したコマンドの結果文字列が取得できること YES YES YES
5 起動したコマンドの戻り値が取得できること YES YES YES
6 OpenSSH形式の鍵(ssh-keygenのデフォルト)が使えること NO YES YES
7 PEM形式の鍵(ssh-keygen -m PEM)が使えること YES YES YES
8 大量の標準出力でStreamが詰まらないこと YES YES YES
9 JDK17のデフォルトでECDSA 384bitが使えること YES YES NO
10 JDK17のデフォルトでssh-ed25519が使えること NO NO NO

その他の差異

使いわけ

  • authorized_keysに書かれたコマンドを実行するという観点では、どれについても大きな違いはないだろう。実装量についても、上記で試した範囲についてはそれほど変わらなさそう。
  • すでに使用中のSSH鍵が作られていて、その鍵の形式がOpenSSH形式で、その鍵をJavaの処理でも同じように使いたい場合(鍵を変えたり、新しく鍵を作ったりしたくない場合)はApache Mina SSHDかTrilead SSHを使う必要がある。
  • Trilead SSHはEdDSAを使っていなくてもEdDSA-Javaがないと接続時にエラーになってしまう。EdDSA-Javaは2019年からメンテナンスが止まっている。
  • JSchはHinemosApache CamelJBoss EAP 7の内部でも使われているので、問題が見つかったときには誰か何かアクションを起こすだろう。Trilead SSHはJenkinsによる修正が期待されるが、依存先のEdDSA-Javaのメンテナンスが止まっているのが懸念事項。Apache Mina SSHDは現在も活発にメンテナンスされているように見える。JSchのForkについての評価は、できて日が浅いこともあって未知数の状態。
  • Trilead SSHは「authorized_keysに指定されたコマンドの戻り値を取得する際は空のコマンドを実行しないとSession#waitForCondition()が返ってこない」「コマンドの戻り値の取得に謎のウェイトを入れる必要がある」など使用法にだいぶクセがある。

*1:認証エラーなどの場合は例外が返る。そうではなく、実装がまずくて、通信をクローズする前に戻り値を取得しようとした場合などの話。