Status Code 303 - See Other

サーバサイド、iOS・アンドロイドアプリ、インフラレベルの話まで幅広くやってます。情報の誤りや指摘・意見などは自由にどうぞ。

SSHJ による暗号化通信

概要

様々な処理をサーバに任せ、その処理結果だけをサーバから取得したい場合がある。

このような通信を行う際、FTPTELNET を用いて行うと
通信が暗号化されないため、その内容を第三者に盗聴されるリスクを生んでしまう。

ここでは、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

今回の SSH のコアとなるライブラリ

  • sshj-0.15.0.jar
SSHJ を使用する場合に必要なライブラリ

暗号化ライブラリ

  • bcpkix-jdk15on-154.jar
  • bcprov-jdk15on-154.jar
  • ecc-25519-java-1.0.1.jar

圧縮ライブラリ (ダウンロード/アップロードで必要)

  • jzlib-1.1.3.jar

ログ出力ライブラリ

Apache Mina の提供する SSH ライブラリ

  • sshd-core-1.0.0.jar
ボイラープレートコード排除

クラス

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 = [パスワード、公開鍵認証なら空でも良い]

使い方

  1. ssh.properties を変更する
    • ssh.host, ssh.port, ssh.user は設定必須
    • パスワード認証 → ssh.password 設定
    • 公開鍵認証 → ssh.keyfile 設定 (今回は、OpenSSLキーファイルのみ対応)
  2. ロジックを記述する

(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
known_hostsファイルを読み、それに記述されているホストを正当な接続先とする
useCompression
データを圧縮して通信を行う (JZLib が必要)
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 クラスを変更するだけなので、取り替えは容易になる。
(またこのメソッドを直接ユーザに使わせないよう、ストラテジーのメソッドアクセスレベルはパッケージにしている)
ただし、この拡張はコードが複雑になるため、本当に必要なときだけ行った方が良い。

また、ここでは触れないが、認証方法に関しても様々なものが用意されているため、
本家のリファレンスは見ておくといいかもしれない。