「Android SDK 統合フロー」の Topic では、Android SDK のインポート、設定、IP アドレスの解析、ネットワークライブラリへの適用、統合の検証までの完全なフローを学習しました。この Topic では、HTTPDNS と HttpURLConnection を統合する方法について説明します。
1. はじめに
このドキュメントでは、HTTPDNS を統合して、サーバ名表示 (SNI) を必要とするシナリオを含む HTTPS シナリオで、Android 上の HttpURLConnection リクエストの IP ダイレクト接続を実装する方法について説明します。基盤となる原則の詳細については、「HTTPDNS を使用した IP ダイレクト接続の仕組み」をご参照ください。
Android の主流のネットワーク開発フレームワークのほとんどは OkHttp に切り替わっています。OkHttp は、カスタム DNS サービス用のインターフェイスをネイティブに提供しており、これにより IP ダイレクト接続をよりシンプルかつエレガントに実装できます。統合については、まず「Android で OkHttp と HTTPDNS を使用するためのベストプラactice」をご参照ください。以下の内容は、OkHttp を使用できないシナリオで HttpURLConnection を使用する代替ソリューションを提供します。
2. 統合ソリューション
ソリューションは、シナリオに SNI が含まれるかどうかによって異なります。2 つのシナリオがあります。
HTTPS シナリオ (SNI):
SSLSocketを作成するときに、SNI を介して元のドメイン名をサーバーに渡します。また、`HostnameVerifier` のロジックを正しく処理する必要があります。HTTPS シナリオ (非 SNI): HostnameVerifier インターフェイスを使用して、証明書の検証中に IP アドレスを元のドメイン名に戻して検証できます。
以下のセクションでは、完全な統合プロセスについて説明し、各シナリオの例を示します。
2.1 HTTPS シナリオ (SNI)
マルチドメイン証明書をデプロイしており、ハンドシェイクの前に SNI を介してサーバーにドメイン名を提供する必要があるシナリオでは、HostnameVerifier を使用することに加えて、SSLSocket を作成するときに正しい SNI 名を設定する必要があります。これを行うには、カスタム SSLSocketFactory を作成し、その createSocket() メソッドで次の操作を実行します。
HTTPDNS によって解決された IP アドレスでドメイン名を置き換えて、接続を確立します。
システムまたはカスタムの
SSLCertificateSocketFactoryを呼び出し、ハンドシェイクの前にsetHostname()を使用して SNI ホスト名を設定します。証明書の検証を自分で行います。検証に使用するドメイン名を IP アドレスから元のドメイン名に戻します。
公式の HTTPDNS Android デモでは、SNI シナリオで `HttpsURLConnection` と HTTPDNS を使用するためのサンプルコードが提供されています。
カスタム SSLSocketFactory の例
class TlsSniSocketFactory constructor(conn: HttpsURLConnection): SSLSocketFactory() {
private val mConn: HttpsURLConnection
init {
mConn = conn
}
override fun createSocket(plainSocket: Socket?, host: String?, port: Int, autoClose: Boolean): Socket {
var peerHost = mConn.getRequestProperty("Host")
if (peerHost == null) {
peerHost = host
}
val address = plainSocket!!.inetAddress
if (autoClose) {
// plainSocket は不要です。
plainSocket.close()
}
// SSL ソケットを作成して接続しますが、ホスト名や証明書の検証はまだ行いません。
val sslSocketFactory =
SSLCertificateSocketFactory.getDefault(0) as SSLCertificateSocketFactory
val ssl = sslSocketFactory.createSocket(address, R.attr.port) as SSLSocket
// 利用可能な場合は TLSv1.1 と TLSv1.2 を有効にします。
ssl.enabledProtocols = ssl.supportedProtocols
// ハンドシェイクの前に SNI を設定します。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
// SNI ホスト名を設定します。
sslSocketFactory.setHostname(ssl, peerHost)
} else {
// 4.2 より前の Android には、文書化された SNI サポートはありません。リフレクションを試みます。
try {
val setHostnameMethod = ssl.javaClass.getMethod(
"setHostname",
String::class.java
)
setHostnameMethod.invoke(ssl, peerHost)
} catch (e: Exception) {
}
}
// ホスト名と証明書を検証します。
val session = ssl.session
if (!HttpsURLConnection.getDefaultHostnameVerifier()
.verify(peerHost, session)
) throw SSLPeerUnverifiedException(
"Cannot verify hostname: $peerHost"
)
return ssl
}
}public class TlsSniSocketFactory extends SSLSocketFactory {
private HttpsURLConnection mConn;
public TlsSniSocketFactory(HttpsURLConnection conn) {
mConn = conn;
}
@Override
public Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {
String peerHost = mConn.getRequestProperty("Host");
if (peerHost == null)
peerHost = host;
InetAddress address = plainSocket.getInetAddress();
if (autoClose) {
// plainSocket は不要です。
plainSocket.close();
}
// SSL ソケットを作成して接続しますが、ホスト名や証明書の検証はまだ行いません。
SSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);
SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);
// 利用可能な場合は TLSv1.1 と TLSv1.2 を有効にします。
ssl.setEnabledProtocols(ssl.getSupportedProtocols());
// ハンドシェイクの前に SNI を設定します。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
// SNI ホスト名を設定します。
sslSocketFactory.setHostname(ssl, peerHost);
} else {
// 4.2 より前の Android には、文書化された SNI サポートはありません。リフレクションを試みます。
try {
java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);
setHostnameMethod.invoke(ssl, peerHost);
} catch (Exception e) {
}
}
// ホスト名と証明書を検証します。
SSLSession session = ssl.getSession();
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(peerHost, session))
throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);
return ssl;
}
}リダイレクト処理の例
SNI シナリオのリクエストは、しばしば複数の HTTP 3xx リダイレクトを受けます。この例では、リダイレクト中に HTTPDNS を使用して新しいホストを解決し、リクエストを続行する方法を示します。
fun recursiveRequest(path: String) {
var conn: HttpURLConnection? = null
try {
val url = URL(path)
conn = url.openConnection() as HttpURLConnection
// 同期 API を使用して IP アドレスを取得します。
val httpdnsResult = HttpDns.getService(accountID)
.getHttpDnsResultForHostSync(url.host, RequestIpType.both)
var ip: String? = null
if (httpdnsResult.ips != null && httpdnsResult.ips.isNotEmpty()) {
ip = httpdnsResult.ips[0]
} else if (httpdnsResult.ipv6s != null && httpdnsResult.ipv6s.isNotEmpty()) {
ip = httpdnsResult.ipv6s[0]
}
if (!TextUtils.isEmpty(ip)) {
// HTTPDNS から IP アドレスが取得された場合は、URL を置き換えて Host ヘッダーを設定します。
val newUrl = path.replaceFirst(url.host.toRegex(), ip!!)
conn = URL(newUrl).openConnection() as HttpURLConnection
conn.connectTimeout = 30000
conn.readTimeout = 30000
conn.instanceFollowRedirects = false
// HTTP リクエストヘッダーに Host フィールドを設定します。
conn.setRequestProperty("Host", url.host)
if (conn is HttpsURLConnection) {
val httpsURLConnection = conn
// HTTPS シナリオの場合は、証明書の検証を実行します。
httpsURLConnection.hostnameVerifier =
HostnameVerifier { _, session ->
var host = httpsURLConnection.getRequestProperty("Host")
if (null == host) {
host = httpsURLConnection.url.host
}
HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session)
}
// SNI シナリオの場合は、SSLSocket を作成します。
httpsURLConnection.sslSocketFactory = TlsSniSocketFactory(httpsURLConnection)
}
}
val code = conn.responseCode // ネットワークブロック
if (code in 300..399) {
var location = conn.getHeaderField("Location")
if (location == null) {
location = conn.getHeaderField("location")
}
if (location != null) {
if (!(location.startsWith("http://") || location
.startsWith("https://"))
) {
// ホストが省略され、パスのみが返されることがあります。この場合、URL を補完する必要があります。
val originalUrl = URL(path)
location = (originalUrl.protocol + "://"
+ originalUrl.host + location)
}
recursiveRequest(location)
}
} else {
// リダイレクト完了。
val dis = DataInputStream(conn.inputStream)
var len: Int
val buff = ByteArray(4096)
val response = StringBuilder()
while (dis.read(buff).also { len = it } != -1) {
response.append(String(buff, 0, len))
}
Log.d(TAG, "Response: $response")
}
} catch (e: MalformedURLException) {
Log.w(TAG, "recursiveRequest MalformedURLException")
} catch (e: IOException) {
Log.w(TAG, "recursiveRequest IOException")
} catch (e: java.lang.Exception) {
Log.w(TAG, "unknow exception")
} finally {
conn?.disconnect()
}
}public void recursiveRequest(String path) {
HttpURLConnection conn = null;
try {
URL url = new URL(path);
conn = (HttpURLConnection) url.openConnection();
// 同期 API を使用して IP アドレスを取得します。
HTTPDNSResult httpdnsResult = HttpDns.getService(accountID).getHttpDnsResultForHostSync(url.getHost(), RequestIpType.both);
String ip = null;
if (httpdnsResult.getIps() != null && httpdnsResult.getIps().length > 0) {
ip = httpdnsResult.getIps()[0];
} else if (httpdnsResult.getIpv6s() != null && httpdnsResult.getIpv6s().length > 0) {
ip = httpdnsResult.getIpv6s()[0];
}
if (!TextUtils.isEmpty(ip)) {
// HTTPDNS から IP アドレスが取得された場合は、URL を置き換えて Host ヘッダーを設定します。
String newUrl = path.replaceFirst(url.getHost(), ip);
conn = (HttpURLConnection) new URL(newUrl).openConnection();
conn.setConnectTimeout(30000);
conn.setReadTimeout(30000);
conn.setInstanceFollowRedirects(false);
// HTTP リクエストヘッダーに Host フィールドを設定します。
conn.setRequestProperty("Host", url.getHost());
if (conn instanceof HttpsURLConnection) {
final HttpsURLConnection httpsURLConnection = (HttpsURLConnection) conn;
// HTTPS シナリオの場合は、証明書の検証を実行します。
httpsURLConnection.setHostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
String host = httpsURLConnection.getRequestProperty("Host");
if (null == host) {
host = httpsURLConnection.getURL().getHost();
}
return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);
}
});
// SNI シナリオの場合は、SSLSocket を作成します。
httpsURLConnection.setSSLSocketFactory(new TlsSniSocketFactory(httpsURLConnection));
}
}
int code = conn.getResponseCode();// ネットワークブロック
if (code >= 300 && code < 400) {
String location = conn.getHeaderField("Location");
if (location == null) {
location = conn.getHeaderField("location");
}
if (location != null) {
if (!(location.startsWith("http://") || location
.startsWith("https://"))) {
// ホストが省略され、パスのみが返されることがあります。この場合、URL を補完する必要があります。
URL originalUrl = new URL(path);
location = originalUrl.getProtocol() + "://"
+ originalUrl.getHost() + location;
}
recursiveRequest(location);
}
} else {
// リダイレクト完了。
DataInputStream dis = new DataInputStream(conn.getInputStream());
int len;
byte[] buff = new byte[4096];
StringBuilder response = new StringBuilder();
while ((len = dis.read(buff)) != -1) {
response.append(new String(buff, 0, len));
}
Log.d(TAG, "Response: " + response.toString());
}
} catch (MalformedURLException e) {
Log.w(TAG, "recursiveRequest MalformedURLException");
} catch (IOException e) {
Log.w(TAG, "recursiveRequest IOException");
} catch (Exception e) {
Log.w(TAG, "unknow exception");
} finally {
if (conn != null) {
conn.disconnect();
}
}
}2.2 HTTPS シナリオ (非 SNI)
シングルドメイン証明書のみを使用するシナリオ、つまりマルチドメインのデプロイメントがない、または SNI でドメイン名を指定する必要がないシナリオでは、主な変更点はホスト名の検証にあります。原則は、証明書の検証プロセス中に独自の HostnameVerifier をフックまたは実装することです。これにより、検証に使用するドメイン名を IP アドレスから元のドメイン名に戻すことができます。
重要 このソリューションは、非 SNI シナリオにのみ適用されます。アプリケーションが複数の証明書または複数のドメイン名を使用する場合は、SNI が不要であることを確認してください。SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found. などのエラーが発生した場合は、まずターゲットサイトが SNI サポートを必要とするかどうかを確認してください。次の例では、HttpURLConnection を使用して HostnameVerifier インターフェイスを使用して証明書の検証を完了する方法を示します。
try {
val url = "https://140.205.XX.XX/?sprefer=sypc00"
val connection = URL(url).openConnection() as HttpURLConnection
connection.setRequestProperty("Host", "m.taobao.com")
if (connection is HttpsURLConnection) {
connection.hostnameVerifier = HostnameVerifier { _, session ->
/*
* 公式ドキュメントでは、このインターフェイスについて次のように説明されています。
* 「これは、実装者が提供できる拡張検証オプションです。
* URL のホスト名がピアの識別ホスト名と一致しない場合にハンドシェイク中に使用されます。」
*
* HTTPDNS を使用する場合、URL のホスト名は m.taobao.com などのリモートホスト名ではありません。
* これにより、証明書が発行されたドメインとの不一致が発生します。
* Android の HttpsURLConnection は、このカスタムシナリオを処理するためのコールバックインターフェイスを提供します。
* HTTPDNS からのオリジン IP アドレスがセッションの IP 情報と一致することを確認した後、
* コールバックメソッドで検証対象のドメイン名を元のドメイン名に置き換えます。
*
*/
HttpsURLConnection.getDefaultHostnameVerifier().verify("m.taobao.com", session)
}
}
connection.connect()
} catch (e: java.lang.Exception) {
e.printStackTrace()
}try {
String url = "https://140.205.XX.XX/?sprefer=sypc00";
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestProperty("Host", "m.taobao.com");
if (connection instanceof HttpsURLConnection) {
connection.setHostnameVerifier(new HostnameVerifier() {
/*
* 公式ドキュメントでは、このインターフェイスについて次のように説明されています。
* 「これは、実装者が提供できる拡張検証オプションです。
* URL のホスト名がピアの識別ホst名と一致しない場合にハンドシェイク中に使用されます。」
*
* HTTPDNS を使用する場合、URL のホスト名は m.taobao.com などのリモートホスト名ではありません。
* これにより、証明書が発行されたドメインとの不一致が発生します。
* Android の HttpsURLConnection は、このカスタムシナリオを処理するためのコールバックインターフェイスを提供します。
* HTTPDNS からのオリジン IP アドレスがセッションの IP 情報と一致することを確認した後、
* コールバックメソッドで検証対象のドメイン名を元のドメイン名に置き換えます。
*
*/
@Override
public boolean verify(String hostname, SSLSession session) {
return HttpsURLConnection.getDefaultHostnameVerifier().verify("m.taobao.com", session);
}
});
}
connection.connect();
} catch (Exception e) {
e.printStackTrace();
}3. まとめ
HTTPDNS と HTTPS を使用する場合の課題
証明書の検証にはドメイン名の一致が必要です。
SNI では、ハンドシェイクの前にサーバーにドメイン名を提供する必要があります。
SNI シナリオ
ハンドシェイクの前に SNI ホスト名を設定するには、カスタムの
SSLSocketFactoryを作成する必要があります。また、HostnameVerifierで証明書検証のためのドメイン名の置き換えを処理する必要もあります。
非 SNI シナリオ
HostnameVerifierを使用するだけで、検証に使用するドメイン名を IP アドレスから元のドメイン名に戻すことができます。
OkHttp の使用を優先
プロジェクトで OkHttp を使用できる場合は、「Android 向け HTTPDNS + OkHttp ベストプラクティス」のベストプラクティスに従うことをお勧めします。OkHttp はカスタム DNS 用のインターフェイスをネイティブに提供しているため、コードがより簡潔になり、汎用性が向上します。
これで、Android で HttpURLConnection と HTTPDNS を使用して、SNI を含む HTTPS の IP ダイレクト接続を実装する方法がわかりました。この Topic は、統合を完了するのに役立ちます。