背景
JavaによるOLTP処理の中で、「別サーバのauthorized_keys
に指定されたコマンドをパスフレーズなしのSSH鍵でキックし、その戻り値を確認する」という処理を行いたい場合、外部コマンドとしてSSHを起動するのはいかにも手間がかかる。
そこで、Javaで実装されたSSHクライアントライブラリについて、上記のような使いかたができるか確認した。
比較対象としては、Mavenで利用プロジェクトの多いSSHクライアントライブラリ上位2つであるJSchとApache Mina SSHD、およびこれらに依存しない独自の実装としてJenkinsでメンテナンスされているTrilead SSH(旧Ganymed SSH2)を取りあげた。ちなみに、他のライブラリとしてはsshjやApache jcloudsのSSHクライアントがある。ただ、sshjのバックエンドはApache Mina SSHDのようだし、jcloudsはsshjを使ったバックエンドとJSchを使ったバックエンドからの選択式のようなので、結局最終的には今回確認したライブラリのどれかが動くという話になりそう。
ほかにも旧SSHTools J2SSHが、現在はMaverick Synergyという名前でリリースされている。LGPLv3と商用ライセンスの選択式なのだが、これについては未調査。
比較に使用したコード
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 |
11 | ライセンス | BSD-like | Apache 2.0 | BSD-like |
12 | ドキュメント | http://www.jcraft.com/jsch/examples/ | https://github.com/apache/mina-sshd | - |
13 | 秘密鍵を文字列で渡せる | YES | NO | Deprecated |
14 | エラーハンドリング | JSchException | IOException | IOException |
15 | 接続のタイムアウト設定 | YES | YES | YES |
16 | URL | http://www.jcraft.com/jsch/ | https://mina.apache.org/sshd-project/ | https://github.com/jenkinsci/trilead-ssh2 |
その他の差異
- デフォルトで得られるコンソール出力結果の形式が異なる。
- デフォルトで得られるコンソール出力の範囲が異なる。
- 実装がまずくて戻り値が取得できなかったとき*1の戻り値が異なる。
- 開発の活発さ
- JSchは現状開発が止まっていてForkができている。
- Apache Mina SSHDは2022年現在も開発が続いている。
- Trilead SSHは、ETH ZurichによるGanymed SSH-2のメンテナンス、TMate SoftwareによるTrilead SSHのメンテナンスは活動していないようだが、JenkinsによるTrilead SSH-2のメンテナンスが続いている。
使いわけ
authorized_keys
に書かれたコマンドを実行するという観点では、どれについても大きな違いはないだろう。実装量についても、上記で試した範囲についてはそれほど変わらなさそう。- すでに使用中のSSH鍵が作られていて、その鍵の形式がOpenSSH形式で、その鍵をJavaの処理でも同じように使いたい場合(鍵を変えたり、新しく鍵を作ったりしたくない場合)はApache Mina SSHDかTrilead SSHを使う必要がある。
- Trilead SSHはEdDSAを使っていなくてもEdDSA-Javaがないと接続時にエラーになってしまう。EdDSA-Javaは2019年からメンテナンスが止まっている。
- JSchはHinemosやApache CamelやJBoss EAP 7の内部でも使われているので、問題が見つかったときには誰か何かアクションを起こすだろう。Trilead SSHはJenkinsによる修正が期待されるが、依存先のEdDSA-Javaのメンテナンスが止まっているのが懸念事項。Apache Mina SSHDは現在も活発にメンテナンスされているように見える。JSchのForkについての評価は、できて日が浅いこともあって未知数の状態。
- Trilead SSHは「
authorized_keys
に指定されたコマンドの戻り値を取得する際は空のコマンドを実行しないとSession#waitForCondition()
が返ってこない」「コマンドの戻り値の取得に謎のウェイトを入れる必要がある」など使用法にだいぶクセがある。
*1:認証エラーなどの場合は例外が返る。そうではなく、実装がまずくて、通信をクローズする前に戻り値を取得しようとした場合などの話。