SSHJ による暗号化通信
概要
様々な処理をサーバに任せ、その処理結果だけをサーバから取得したい場合がある。
このような通信を行う際、FTP や TELNET を用いて行うと
通信が暗号化されないため、その内容を第三者に盗聴されるリスクを生んでしまう。
ここでは、SSH による暗号化通信を Java で実装する方法について記述する。
また、SSH 用のライブラリは多数存在するが、今回は SSHJ(version 0.15.0) を用いる
実装内容
今回は「サーバと簡単な通信が行える」を前提として、次の機能を提供するクラスを作成。
- 認証 (パスワード/公開鍵(OpenSSHキーファイル)認証)
- リモート先でのコマンド実行
- SCP/SFTP を用いたファイルダウンロード
- SCP/SFTP を用いたファイルアップロード
SCP と SFTP は場合によって使い分ける可能性があると思い、両方使えるようにした。
SCP と SFTP の違いはこちらが参考になる。セキュリティプロトコルマスター(8):軽快なscpか高機能なsftp、sshサーバに向いているのは? (1/2) - @IT
今回は、 SSH 用のライブラリを今後取り替える可能性も考慮して、ロジックにこれらを記述せず
Facade クラス(Facade - GoF デザインパターン - Status Code 303 - See Other) を作成、これらを利用する。
詳細コード
ビルドパス設定
SSHJ を使用する場合に必要なライブラリ
暗号化ライブラリ
圧縮ライブラリ (ダウンロード/アップロードで必要)
- jzlib-1.1.3.jar
ログ出力ライブラリ
- sshd-core-1.0.0.jar
ボイラープレートコード排除
- lombok.jar (Lombok ライブラリ - Status Code 303 - See Other)
クラス
SSHJ のFacade クラス (SSHClientFacade.java)
package communicate.ssh; import java.io.File; import java.io.IOException; import java.util.concurrent.TimeUnit; import net.schmizz.sshj.SSHClient; import net.schmizz.sshj.common.IOUtils; import net.schmizz.sshj.connection.channel.direct.Session; import net.schmizz.sshj.connection.channel.direct.Session.Command; import net.schmizz.sshj.transport.TransportException; import net.schmizz.sshj.transport.verification.PromiscuousVerifier; import net.schmizz.sshj.userauth.UserAuthException; import net.schmizz.sshj.userauth.keyprovider.FileKeyProvider; import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile; import net.schmizz.sshj.xfer.FileSystemFile; public class SSHClientFacade implements AutoCloseable { /** * SSH Client */ SSHClient ssh = new SSHClient(); /** * Connect server. * * @param host * @param port * @throws IOException */ public void connect(String host, int port) throws IOException { ssh.useCompression(); // need jzlib ssh.addHostKeyVerifier(new PromiscuousVerifier()); ssh.connect(host, port); } /** * Add user information with key file. * * @param user * @param keyFile * @throws TransportException * @throws UserAuthException */ public void authPublickey(String user, File keyFile) throws UserAuthException, TransportException { FileKeyProvider provider = new OpenSSHKeyFile(); provider.init(keyFile); ssh.authPublickey(user, provider); } /** * Add user information with password. * * @param user * @param password * @throws IOException */ public void authPassword(String user, String password) throws UserAuthException, TransportException { ssh.authPassword(user, password); } /** * Execute Command. * * @param command * @return SSH Result * @throws IOException */ public SSHResult execute(String command) throws IOException { try (final Session session = ssh.startSession()) { Command cmd = session.exec(command); cmd.join(30, TimeUnit.SECONDS); String output = IOUtils.readFully(cmd.getInputStream()).toString(); Integer status = cmd.getExitStatus(); if (status == 0) { return new SSHResult(status, output, ""); } return new SSHResult(status, output, IOUtils.readFully(cmd.getErrorStream()).toString()); } } /** * Download file. * * @param src * Source file path (remote) * @param dest * Destination directory or file path (local) * @param method * SCP or SFTP * @throws IOException */ public void download(String src, File dest, TransferMethod method) throws IOException { method.download(ssh, src, dest); } /** * Upload file. * * @param src * Source file path (local) * @param dest * Destination directory or file path (remote) * @param method * @throws IOException */ public void upload(File src, String dest, TransferMethod method) throws IOException { method.upload(ssh, src, dest); } /** * Tear down. * * @throws IOException */ @Override public void close() throws IOException { try { ssh.close(); } finally { ssh.disconnect(); } } /** * Transfer method strategy. * */ public static enum TransferMethod { SCP { @Override void download(SSHClient ssh, String src, File dest) throws IOException { ssh.newSCPFileTransfer().download(src, new FileSystemFile(dest)); } @Override void upload(SSHClient ssh, File src, String dest) throws IOException { ssh.newSCPFileTransfer().upload(new FileSystemFile(src), dest); } }, SFTP { @Override void download(SSHClient ssh, String src, File dest) throws IOException { ssh.newSFTPClient().get(src, new FileSystemFile(dest)); } @Override void upload(SSHClient ssh, File src, String dest) throws IOException { ssh.newSFTPClient().put(new FileSystemFile(src), dest); } }; /** * Download File. * * @param ssh * SSHClient * @param src * Source file(remote) * @param dest * Destination directory or file path(local) * @throws IOException */ abstract void download(SSHClient ssh, String src, File dest) throws IOException; /** * Upload File. * * @param ssh * SSHClient * @param src * Source file(local) * @param dest * Destination directory or file path(remote) * @throws IOException */ abstract void upload(SSHClient ssh, File src, String dest) throws IOException; } }
SSHClient から返る結果を格納するクラス (SSHResult.java)
package communicate.ssh; import lombok.Value; @Value public class SSHResult { /** * Exit status of command. */ public int status; /** * Output. */ public String output; /** * Error. */ public String error; }
設定ファイル (ssh.properties)
# Connection Settings ssh.host = [必須, IPアドレス、ホスト名どちらでも可] ssh.port = [必須] # User Authentication ssh.user = [必須] ssh.keyfile = [キーファイルのパスを指定、今回はOpenSSH形式のキーファイル対応] ssh.password = [パスワード、公開鍵認証なら空でも良い]
使い方
- ssh.properties を変更する
- ロジックを記述する
(Property クラス → プロパティファイルの使用方法 - Status Code 303 - See Other)
package communicate.ssh; import java.io.File; import java.io.IOException; import communicate.ssh.SSHClientFacade.TransferMethod; import resources.properties.Property; public class Main { public static void main(String[] args) throws IOException { Property property = new Property("ssh"); try (SSHClientFacade ssh = new SSHClientFacade()) { // 接続 ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port")); // 認証 ssh.authPublickey(property.getString("ssh.user"), new File(property.getString("ssh.keyfile"))); // ssh.authPassword(property.getString("ssh.user"), property.getString("ssh.password")); // コマンド実行 SSHResult result = ssh.execute("ls -al > test.txt"); System.out.println(result); // ファイルのダウンロード ssh.download("/home/pi/test.txt", new File("./sample"), TransferMethod.SCP); // SCP の場合 // ssh.download("~/test.txt", new File("./"), TransferMethod.SFTP); //SFTP の場合 // ファイルのアップロード ssh.upload(new File("./sample"), "/home/pi/", TransferMethod.SCP); // SCP の場合 // ssh.upload(new File("./test.txt"), "/home/pi/", TransferMethod.SFTP); // STFP の場合 } } }
注意点
リモート先のパスは、なるべくフルパスで記述した方が良い。不思議なことに、ヘテムルでは
download メソッドの引数ではホームディレクトリ(~/xxx/yyy) を解釈するが、upload 時は認識できないようで、
下記の例外が発生した。なお、フルパスに変更すると動作した。
Exception in thread "main" net.schmizz.sshj.xfer.scp.SCPRemoteException: Remote SCP command had error: scp: ~/xxx/yyy: No such file or directory
接続方法を様々に変更する拡張
今回は単純化のため、サーバへの接続方法が同じであると仮定して実装した。
しかし、様々なサーバに接続する際に、その接続設定が異なることもあるだろう。
これらは、SSHClient の設定を変更することで実現できる。
接続方法の追加
まず SSHClientFacade クラスに以下を追加する。
// アクセス方法に関する Strategy public static interface AccessStrategy { public abstract void execute(SSHClient ssh) throws IOException; } // connect メソッドを追加する public void connect(String host, int port, AccessStrategy method) throws IOException { method.execute(ssh); ssh.connect(host, port); }
そして、ロジックでは次のように使う想定。
ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port"), new AccessStrategy(){ @Override public void execute(SSHClient ssh) throws IOException { ssh.setConnectTimeout(60 * 1000); ssh.setTimeout(30 * 60 * 1000); ssh.loadKnownHosts(); ssh.addHostKeyVerifier(new PromiscuousVerifier()); } });
これで、サーバによって柔軟な設定が可能になり、SSHClient を次のメソッドを用いて設定する。
- addHostKeyVerifier
- addAlgorithmsVerifier
- loadKnownHosts
- useCompression
- setSocketFactory
- setTimeout
- setConnectTimeout
Facade を崩さない
このままではロジック部分が SSHJ を意識しなければならず、 SSHJ 特有の設定が SSHClientFacade 外部に漏れてしまう。
これでは、Facade を作った意味がない。なので、これらアクセス方法を定義した Strategy パターンを Enum 型で実装し、利用する。
実装一例。下記を SSHClientFacade に追加。
/** * Definition how to access to your server. * */ public static enum AccessStrategy { SERVER_A { @Override void execute(SSHClient ssh) throws IOException { ssh.setConnectTimeout(60 * 1000); ssh.setTimeout(30 * 60 * 1000); ssh.loadKnownHosts(); ssh.addHostKeyVerifier(new PromiscuousVerifier()); } }, SERVER_B { @Override void execute(SSHClient ssh) throws IOException { ssh.setConnectTimeout(10 * 1000); ssh.setTimeout(10 * 60 * 1000); ssh.loadKnownHosts(); } }; /** * write your settings. * @param ssh * @throws IOException */ abstract void execute(SSHClient ssh) throws IOException; } /** * Access server with your customize settings. * @param host * @param port * @param method * @throws IOException */ public void connect(String host, int port, AccessStrategy method) throws IOException { method.execute(ssh); ssh.connect(host, port); }
ロジック部の使用例
ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port"), AccessStrategy.SERVER_A);
SSH はセキュリティ上重要なため、ライブラリ開発者の都合によっては、今後取り替えることを想定すべきかもしれない。
もしそうであったとしても、おそらくサーバの接続方法や設定は概念的には変わらない。
このように設計しておけば、ロジックの変更は SSHClientFacade クラスを変更するだけなので、取り替えは容易になる。
(またこのメソッドを直接ユーザに使わせないよう、ストラテジーのメソッドアクセスレベルはパッケージにしている)
ただし、この拡張はコードが複雑になるため、本当に必要なときだけ行った方が良い。
また、ここでは触れないが、認証方法に関しても様々なものが用意されているため、
本家のリファレンスは見ておくといいかもしれない。