JavaでSNIの有効・無効を通信ごとに切り替える

# 課題
https通信を行う際、サーバー側の設定によってSNI（[Wikipedia](https://ja.wikipedia.org/wiki/Server_Name_Indication)参照）を有効にすべき場合と、逆にSNIを無効すべき場合とがある。
これが原因で接続に失敗した場合は以下の例外が発生する。

```
javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure
```

Java7以降ではデフォルトで有効に設定されていて、JVMの起動オプションに「-Djsse.enableSNIExtension=false」を指定することで無効化できる。（[参考記事](http://ttshiko.hatenablog.jp/entry/20121103/1351927402)）
ただし、この方法ではシステム全体を有効・無効のどちらかに設定するため、接続先によって有効・無効を切り替えたい場合にうまくいかない。
なお、1.8.0_141未満のバージョンではconnection.setHostnameVerifierを呼び出すことで副次的にSNIを無効化できたが、1.8.0_141でこの挙動は修正され常に起動オプションに従うようになった。

# 解決策
起動オプションではSNIを有効にした状態で、SocketFactoryにてSocketを作成する際にsetServerNamesメソッドに空のリストを渡してあげることで、通信単位で個別に無効化できた。

```Main.java
import java.io.*;
import java.net.*;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import javax.net.ssl.*;
import sun.security.ssl.SSLSocketImpl;

public class Main {
	public static void connect(String url, boolean disableSni, boolean disableHostnameCheck) throws IOException, GeneralSecurityException {
		HttpsURLConnection connection;
		connection = (HttpsURLConnection)(new URL(url)).openConnection();
		connection.setRequestMethod("GET");

		// ホスト名チェック無効化
		if (disableHostnameCheck) {
			connection.setHostnameVerifier(new HostnameVerifier() {
				public boolean verify(String hostname, SSLSession session) {
					return true;
				}
			});
		}
		// 証明書チェック無効化
		SSLContext sslContext = SSLContext.getInstance("SSL");
		sslContext.init(null, new X509TrustManager[] {new RelaxedX509TrustManager()}, new java.security.SecureRandom());
		SSLSocketFactory socketFactory = sslContext.getSocketFactory();
		// SNI無効化
		if (disableSni) {
			socketFactory = new SniDisabledSSLSocketFactory(socketFactory);
		}
		// 設定をconnectionに反映
		connection.setSSLSocketFactory(socketFactory);

		// 結果を取得して出力
		connection.connect();
		int responseCode = connection.getResponseCode();
		System.out.println("ResponseCode : " + responseCode);
		InputStream input = null;
		try {
			input = connection.getInputStream();
		} catch (IOException e) {
			input = connection.getErrorStream();
		}
		BufferedReader reader = new BufferedReader(new InputStreamReader(input));
		while (true) {
			String line = reader.readLine();
			if (line == null) {
				break;
			}
			System.out.println(line);
		}
	}

	public static void main(String[] args) throws Exception {
		connect("https://www.example.com/", false, true);
		connect("https://www.example.com/", true, true);
		connect("https://www.example.com/", false, false);
		connect("https://www.example.com/", true, false); // このパターンだけ未テスト
	}
}

/**
 * jsse.enableSNIExtensionの設定に関わらずSNIを無効にするSSLSocketFactory
 */
class SniDisabledSSLSocketFactory extends SSLSocketFactory {
	private SSLSocketFactory baseSocketFactory;
	public SniDisabledSSLSocketFactory(SSLSocketFactory baseSocketFactory) {
		this.baseSocketFactory = baseSocketFactory;
	}
	private Socket setSni(Socket socket) {
		SSLParameters params = ((SSLSocketImpl)socket).getSSLParameters();
		params.setServerNames(new ArrayList<SNIServerName>()); // ホスト名を空にすることでSNIを無効にする
		((SSLSocketImpl)socket).setSSLParameters(params);
		return socket;
	}
	@Override
	public String[] getDefaultCipherSuites() {
		return baseSocketFactory.getDefaultCipherSuites();
	}
	@Override
	public String[] getSupportedCipherSuites() {
		return baseSocketFactory.getSupportedCipherSuites();
	}
	@Override
	public Socket createSocket(Socket paramSocket, String paramString, int paramInt, boolean paramBoolean) throws IOException {
		return setSni(baseSocketFactory.createSocket(paramSocket, paramString, paramInt, paramBoolean));
	}
	@Override
	public Socket createSocket(String paramString, int paramInt) throws IOException, UnknownHostException {
		return setSni(baseSocketFactory.createSocket(paramString, paramInt));
	}
	@Override
	public Socket createSocket(String paramString, int paramInt1, InetAddress paramInetAddress, int paramInt2) throws IOException, UnknownHostException {
		return setSni(baseSocketFactory.createSocket(paramString, paramInt1, paramInetAddress, paramInt2));
	}
	@Override
	public Socket createSocket(InetAddress paramInetAddress, int paramInt) throws IOException {
		return setSni(baseSocketFactory.createSocket(paramInetAddress, paramInt));
	}
	@Override
	public Socket createSocket(InetAddress paramInetAddress1, int paramInt1, InetAddress paramInetAddress2, int paramInt2) throws IOException {
		return setSni(baseSocketFactory.createSocket(paramInetAddress1, paramInt1, paramInetAddress2, paramInt2));
	}
}

/**
 * 全てを許可するTrustManager
 */
class RelaxedX509TrustManager implements javax.net.ssl.X509TrustManager {
	public boolean isClientTrusted( java.security.cert.X509Certificate[] chain ){
		return true;
	}
	public boolean isServerTrusted( java.security.cert.X509Certificate[] chain ){
		return true;
	}
	public java.security.cert.X509Certificate[] getAcceptedIssuers() {
		return null;
	}
	public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) {}
	public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) {}
}
```

# 動作確認
実際にSNI有効のみを受け付けるサーバー・無効のみを受け付けるサーバーのそれぞれに接続して、目的通り動作することを確認済み。
ただし、「ホスト名チェック有効で接続できる」かつ「SNI無効のみを受け付ける」ようなサーバーを用意できなかったので、そのパターンはテストできていない。そのパターンであっても、パケットキャプチャでSNIの情報が送付されていないことは確認している。

証明書チェック無効化のロジックを削除して、connection.getSSLSocketFactory()で取得したSocketFactoryをベースにSniDisabledSSLSocketFactory を作成した場合は、なぜかうまくいかなかった（=常にSNIが有効化されてしまった）。
SSLContext.getInstance("SSL")で生成したSSLContextを使った上で、適切に証明書チェックを行いつつSNIを無効化できるかどうかは未確認。

# 余談
SNI有効化の効果は、SSL/TSLのClient Helloパケットでホスト名を送付することだけだと思うが、なぜ「SNI有効にするとエラーになるサーバー」があるのかよくわからない。サーバー側のミドルウェアでSNIが有効化されているが設定が不完全、とかだろうか？

冒頭に書いた通りJava 7以降ではSNIがデフォルトで有効化されているが、1.8.0_141未満のバージョンではsetHostnameVerifier(HostnameVerifier v)を呼び出して独自のVerifierを登録している場合にはSNIが無効化されていた。
それが1.8.0_141以降は有効化されるようになった。（[Release Note](http://www.oracle.com/technetwork/java/javase/8u141-relnotes-3720385.html#Change)参照）
これが原因でJavaのアップデート時にハマったのが、この記事を書くきっかけとなった。