通過Android SDK接入流程這篇文檔,您已經瞭解了Android SDK匯入、配置、解析IP、應用到網路程式庫和接入驗證的完整流程,本文主要介紹基於 HttpURLConnection 接入HTTPDNS的具體方案。
1. 前言
本文檔主要介紹在 Android 端使用 HttpURLConnection 進行網路請求時,如何在 HTTPS(含 SNI) 情境下整合 HTTPDNS 並實現 “IP 直連” 的方案,如果您對原理感興趣,可以參考IP直串連入HTTPDNS的原理。
當前 Android 端主流網路開發架構大多已轉向 OkHttp。OkHttp 原生提供自訂 DNS 服務的介面,可更簡潔、優雅地實現 IP 直連。推薦優先參考Android端HTTPDNS+OkHttp最佳實務進行接入。以下內容僅在您業務情境無法使用 OkHttp 時,提供基於 HttpURLConnection 的替代方案。
2. 接入方案
根據是否涉及 SNI,我們可以分成兩種情境討論:
HTTPS 情境(SNI) 則需要在建立
SSLSocket時,將原始網域名稱通過 SNI 方式傳遞給服務端,同時也要做好 HostnameVerifier 相關邏輯。HTTPS 情境(非 SNI) 可以通過 HostnameVerifier 介面,在認證校正時將 IP 還原為原始網域名稱進行驗證。
下面分別介紹這兩種情境下的完整接入流程和參考樣本。
2.1 HTTPS 情境(SNI)
對於部署了多網域名稱認證、需要在握手前通過 SNI 提供網域名稱資訊給伺服器的情境,除了 HostnameVerifier,還需在 建立 SSLSocket 之時 設定正確的 SNI 名稱。為此,需要定製一個 自訂的 SSLSocketFactory,在 createSocket() 方法中進行以下操作:
替換為 HTTPDNS 解析後的 IP 進行串連。
調用系統或自訂的
SSLCertificateSocketFactory,在握手前通過setHostname()設定 SNI Hostname。自行執行認證驗證:將認證校正中的網域名稱從 IP 改回原始網域名稱。
在官方HTTPDNS Android Demo中,我們針對HttpsURLConnection,提供了在SNI情境下使用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) {
// we don't need the plainSocket
plainSocket.close()
}
// create and connect SSL socket, but don't do hostname/certificate verification yet
val sslSocketFactory =
SSLCertificateSocketFactory.getDefault(0) as SSLCertificateSocketFactory
val ssl = sslSocketFactory.createSocket(address, R.attr.port) as SSLSocket
// enable TLSv1.1/1.2 if available
ssl.enabledProtocols = ssl.supportedProtocols
// set up SNI before the handshake
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
// setting sni hostname
sslSocketFactory.setHostname(ssl, peerHost)
} else {
// No documented SNI support on Android <4.2, trying with reflection
try {
val setHostnameMethod = ssl.javaClass.getMethod(
"setHostname",
String::class.java
)
setHostnameMethod.invoke(ssl, peerHost)
} catch (e: Exception) {
}
}
// verify hostname and certificate
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) {
// we don't need the plainSocket
plainSocket.close();
}
// create and connect SSL socket, but don't do hostname/certificate verification yet
SSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);
SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);
// enable TLSv1.1/1.2 if available
ssl.setEnabledProtocols(ssl.getSupportedProtocols());
// set up SNI before the handshake
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
// setting sni hostname
sslSocketFactory.setHostname(ssl, peerHost);
} else {
// No documented SNI support on Android <4.2, trying with reflection
try {
java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);
setHostnameMethod.invoke(ssl, peerHost);
} catch (Exception e) {
}
}
// verify hostname and certificate
SSLSession session = ssl.getSession();
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(peerHost, session))
throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);
return ssl;
}
}重新導向處理樣本
許多 SNI 情境下的請求可能會經歷多次 HTTP 3xx 重新導向。樣本中也展示了如何在重新導向時再次基於 HTTPDNS 解析新的 Host 並繼續發起請求。
fun recursiveRequest(path: String) {
var conn: HttpURLConnection? = null
try {
val url = URL(path)
conn = url.openConnection() as HttpURLConnection
// 同步介面擷取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情境,建立SSLScocket
httpsURLConnection.sslSocketFactory = TlsSniSocketFactory(httpsURLConnection)
}
}
val code = conn.responseCode // Network block
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://"))
) {
//某些時候會省略host,只返回後面的path,所以需要補全url
val originalUrl = URL(path)
location = (originalUrl.protocol + "://"
+ originalUrl.host + location)
}
recursiveRequest(location)
}
} else {
// redirect finish.
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();
// 同步介面擷取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情境,建立SSLScocket
httpsURLConnection.setSSLSocketFactory(new TlsSniSocketFactory(httpsURLConnection));
}
}
int code = conn.getResponseCode();// Network block
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://"))) {
//某些時候會省略host,只返回後面的path,所以需要補全url
URL originalUrl = new URL(path);
location = originalUrl.getProtocol() + "://"
+ originalUrl.getHost() + location;
}
recursiveRequest(location);
}
} else {
// redirect finish.
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 指明網域名稱)的情境,最主要的改動在於 hostname 驗證。具體原理是:在認證校正流程的第二步,通過 hook 或自行實現 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 ->
/*
* 關於這個介面的說明,官方有文檔描述:
* This is an extended verification option that implementers can provide.
* It is to be used during a handshake if the URL's hostname does not match the
* peer's identification hostname.
*
* 使用HTTPDNS後URL裡設定的hostname不是遠端主機名稱(如:m.taobao.com),與憑證發行的域不匹配,
* Android HttpsURLConnection提供了回調介面讓使用者來處理這種定製化情境。
* 在確認HTTPDNS返回的來源站點IP與Session攜帶的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() {
/*
* 關於這個介面的說明,官方有文檔描述:
* This is an extended verification option that implementers can provide.
* It is to be used during a handshake if the URL's hostname does not match the
* peer's identification hostname.
*
* 使用HTTPDNS後URL裡設定的hostname不是遠端主機名稱(如:m.taobao.com),與憑證發行的域不匹配,
* Android HttpsURLConnection提供了回調介面讓使用者來處理這種定製化情境。
* 在確認HTTPDNS返回的來源站點IP與Session攜帶的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 情境
必須自訂
SSLSocketFactory,在握手前設定 SNI Hostname,並在HostnameVerifier中處理認證校正網域名稱的替換。
非 SNI 情境
只需通過
HostnameVerifier把驗證用的網域名稱從 IP 改回原始網域名稱。
優先使用 OkHttp
若專案可用 OkHttp,請優先考慮Android端HTTPDNS+OkHttp最佳實務。其原生提供自訂 DNS 的介面,代碼更簡潔,通用性更好。
至此,您已經瞭解如何在 Android 端通過 HttpURLConnection 實現基於 HTTPDNS 的 HTTPS(含 SNI) “IP 直連” 方案,希望本文能協助您順利完成整合。