Android端打开HttpDns的正确姿势
什么是HttpDns?
DNS服務用于在網絡請求時,將域名轉為IP地址。傳統的基于UDP協議的公共DNS服務極易發生DNS劫持,從而造成安全問題。HttpDns服務則是基于HTTP協議自建DNS服務,或者選擇更加可靠的DNS服務提供商來完成DNS服務,以降低發生安全問題的風險。HttpDns還可以為精準調度提供支持。因而在當前網絡環境中得到了越來越多的應用。
HttpDns的協議則因具體實現而異。通常是客戶端將當前設備的一些信息,比如區域、運營商、網絡的連接方式(WiFi還是移動網絡)以及要解析的域名等傳給HttpDns服務器,服務器為客戶端返回對應的IP地址列表及這些IP地址的有效期等。
新浪的微博團隊有開源自己的HttpDns方案出來,OSC的碼云上項目地址,iOS版,項目的GitHub地址。騰訊有開放自己的HttpDns服務。阿里云 和 DNSPod 還推出了商業化的產品。其他公司在開發自有HttpDns服務時,大多也會參考前人的接口設計,及接入方法,如 普通HTTP請求接入,WebView接入,以及 HTTPS (SNI 與非SNI)接入 等。我們的 HttpDns 服務的設計也參考了一點阿里的思路,然而按照阿里的接入方法接入時卻遇到了一些問題。
HttpDns的基本接入手法及其問題
在移動端,我們通常不會關心Http請求的詳細執行過程,一般是將URL傳給網絡庫,比如OkHttp、Volley、HttpClient或HttpUrlConnection等,簡單的設置一些必要的request header,發起請求,并在請求執行結束之后獲取響應。我們通過HttpDns獲得的只是一些IP地址列表,那要如何將這些IP地址應用到網絡請求中呢?
將由HttpDns獲得的IP地址應用到我們的網絡請求中最簡單的辦法,就是在原有URL的基礎上,將域名替換為IP,然后用新的URL發起HTTP請求。然而,標準的HTTP協議中服務端會將HTTP請求頭中HOST字段的值作為請求的域名,在我們沒有主動設置HOST字段的值時,網絡庫也會自動地從URL中提取域名,并為請求做設置。但使用HttpDns后,URL中的域名信息丟失,會導致默認情況下請求的HOST 頭部字段無法被正確設置,進而導致服務端的異常。為了解決這個問題,需要主動地為請求設置HOST字段值,如:
String originalUrl = "http://www.wolfcstech.com/";URL url = new URL(originalURL);String originalHost = url.getHost();// 同步接口獲取IPString ip = httpdns.getIpByHost(originalHost);HttpURLConnection conn;if (ip != null) {// 通過HTTPDNS獲取IP成功,進行URL替換和HOST頭設置url = new URL(originalUrl.replaceFirst(originalHost, ip));conn = (HttpURLConnection) url.openConnection();// 設置請求HOST字段conn.setRequestProperty("Host", originHost);} else {conn = (HttpURLConnection) url.openConnection();}這樣是可以解決,服務器獲取請求的域名的需要。然而,URL中的域名不只是服務器會用到。在客戶端的網絡庫中,至少還有如下幾個地方同樣需要用到(具體可以參考 OkHttp3連接建立過程分析 和 OkHttp3中的代理與路由 ):
- COOKIE的存取。支持COOKIE存取的網絡庫,在存取COOKIE時,從URL中提取的域名通常是key的重要部分。
- 連接管理。連接的 Keep-Alive參數,可以讓執行HTTP請求的TCP連接在請求結束后不會被立即關閉,而是先保持一段時間。為新發起的請求查找可用連接時,主要的依據也是URL中的域名。針對相同域名同時執行的HTTP請求的最大個數 6 個的限制,也需要借助于URL中的域名來完成。
- HTTPS的SNI及證書驗證。SSL/TLS的SNI擴展用于支持虛擬主機托管。在SSL/TLS握手期間,客戶端通過該擴展將要請求的域名發送給服務器,以便可以取到適當的證書。SNI信息也來源于URL中的域名。
阿里云建議 在使用HttpDns時關閉COOKIE。直接替換原URL中的域名發起請求,會使得對單域名的最大并發連接數限制退化為了對服務器IP地址的最大并發連接數限制;在發起HTTPS請求時,無法正確設置SNI信息只能拿到默認的證書,在域名驗證時,會將IP地址作為驗證的域名而導致驗證失敗。
HTTPS 域名證書驗證問題 (不含SNI) 的解法
許多服務并不是多服務(域名)共用一個物理IP的,因而丟失SNI信息并不是特別的要緊。對于這種場景,解決掉域名證書的驗證問題即可。針對 HttpsURLConnection 接口,方法如下:
try {String url = "https://140.225.164.59/?sprefer=sypc00";final String originHostname = "www.wolfcstech.com";HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();connection.setRequestProperty("Host", originHostname);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信息一致后,您可以在回調方法中將待驗證域名替換為原來的真實域名進行驗證。**/@Overridepublic boolean verify(String hostname, SSLSession session) {return HttpsURLConnection.getDefaultHostnameVerifier().verify(originHostname, session);}});connection.connect();} catch (Exception e) {e.printStackTrace();} finally {}主要思路即是自定義證書驗證的邏輯。HostnameVerifier 的 verify() 傳回來的域名是url中的ip地址,但我們可以在定制的域名證書驗證邏輯中,使用原始的真實的域名與服務器返回的證書一起做驗證。這種解法還算可以。
SNI問題解法一
對于多個域名部署在相同IP地址的主機上的場景,除了要處理域名證書驗證外,SNI的設置也是必須的。阿里云給出的解決方案是,自定義SSLSocketFactory,控制SSLSocket的創建過程。在SSLSocket被創建成功之后,立即設置SNI信息進去。
定制的SSLSocketFactory實現如下:
public class TlsSniSocketFactory extends SSLSocketFactory {private final String TAG = TlsSniSocketFactory.class.getSimpleName();HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();private HttpsURLConnection conn;public TlsSniSocketFactory(HttpsURLConnection conn) {this.conn = conn;}@Overridepublic Socket createSocket() throws IOException {return null;}@Overridepublic Socket createSocket(String host, int port) throws IOException, UnknownHostException {return null;}@Overridepublic Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {return null;}@Overridepublic Socket createSocket(InetAddress host, int port) throws IOException {return null;}@Overridepublic Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {return null;}// TLS layer@Overridepublic String[] getDefaultCipherSuites() {return new String[0];}@Overridepublic String[] getSupportedCipherSuites() {return new String[0];}@Overridepublic Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {String peerHost = this.conn.getRequestProperty("Host");if (peerHost == null)peerHost = host;Log.i(TAG, "customized createSocket. host: " + peerHost);InetAddress address = plainSocket.getInetAddress();if (autoClose) {// we don't need the plainSocketplainSocket.close();}// create and connect SSL socket, but don't do hostname/certificate verification yetSSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);// enable TLSv1.1/1.2 if availablessl.setEnabledProtocols(ssl.getSupportedProtocols());// set up SNI before the handshakeif (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {Log.i(TAG, "Setting SNI hostname");sslSocketFactory.setHostname(ssl, peerHost);} else {Log.d(TAG, "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) {Log.w(TAG, "SNI not useable", e);}}// verify hostname and certificateSSLSession session = ssl.getSession();if (!hostnameVerifier.verify(peerHost, session))throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);Log.i(TAG, "Established " + session.getProtocol() + " connection with " + session.getPeerHost() +" using " + session.getCipherSuite());return ssl;} }HTTPS請求發起過程如下:
public void recursiveRequest(String path, String reffer) {URL url = null;try {url = new URL(path);conn = (HttpsURLConnection) url.openConnection();// 同步接口獲取IPString ip = httpdns.getIpByHostAsync(url.getHost());if (ip != null) {// 通過HTTPDNS獲取IP成功,進行URL替換和HOST頭設置Log.d(TAG, "Get IP: " + ip + " for host: " + url.getHost() + " from HTTPDNS successfully!");String newUrl = path.replaceFirst(url.getHost(), ip);conn = (HttpsURLConnection) new URL(newUrl).openConnection();// 設置HTTP請求頭Host域conn.setRequestProperty("Host", url.getHost());}conn.setConnectTimeout(30000);conn.setReadTimeout(30000);conn.setInstanceFollowRedirects(false);TlsSniSocketFactory sslSocketFactory = new TlsSniSocketFactory(conn);conn.setSSLSocketFactory(sslSocketFactory);conn.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信息一致后,您可以在回調方法中將待驗證域名替換為原來的真實域名進行驗證。**/@Overridepublic boolean verify(String hostname, SSLSession session) {String host = conn.getRequestProperty("Host");if (null == host) {host = conn.getURL().getHost();}return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);}});int code = conn.getResponseCode();// Network blockif (needRedirect(code)) {//臨時重定向和永久重定向location的大小寫有區分String location = conn.getHeaderField("Location");if (location == null) {location = conn.getHeaderField("location");}if (!(location.startsWith("http://") || location.startsWith("https://"))) {//某些時候會省略host,只返回后面的path,所以需要補全urlURL originalUrl = new URL(path);location = originalUrl.getProtocol() + "://"+ originalUrl.getHost() + location;}recursiveRequest(location, path);} 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();}}}private boolean needRedirect(int code) {return code >= 300 && code < 400;}但這種解法是否真的可行呢?OkHttp被集成進AOSP并作為Android Java層的HTTP stack已經有一段時間了,我們就通過OkHttp的代碼來看一下這種方法是否真的可行。
在OkHttp中,TLS的處理主要在RealConnection.connectTls()中:
private void connectTls(ConnectionSpecSelector connectionSpecSelector) throws IOException {Address address = route.address();SSLSocketFactory sslSocketFactory = address.sslSocketFactory();boolean success = false;SSLSocket sslSocket = null;try {// Create the wrapper over the connected socket.sslSocket = (SSLSocket) sslSocketFactory.createSocket(rawSocket, address.url().host(), address.url().port(), true /* autoClose */);// Configure the socket's ciphers, TLS versions, and extensions.ConnectionSpec connectionSpec = connectionSpecSelector.configureSecureSocket(sslSocket);if (connectionSpec.supportsTlsExtensions()) {Platform.get().configureTlsExtensions(sslSocket, address.url().host(), address.protocols());}// Force handshake. This can throw!sslSocket.startHandshake();Handshake unverifiedHandshake = Handshake.get(sslSocket.getSession());// Verify that the socket's certificates are acceptable for the target host.if (!address.hostnameVerifier().verify(address.url().host(), sslSocket.getSession())) {X509Certificate cert = (X509Certificate) unverifiedHandshake.peerCertificates().get(0);throw new SSLPeerUnverifiedException("Hostname " + address.url().host() + " not verified:"+ "\n certificate: " + CertificatePinner.pin(cert)+ "\n DN: " + cert.getSubjectDN().getName()+ "\n subjectAltNames: " + OkHostnameVerifier.allSubjectAltNames(cert));}// Check that the certificate pinner is satisfied by the certificates presented.address.certificatePinner().check(address.url().host(),unverifiedHandshake.peerCertificates());// Success! Save the handshake and the ALPN protocol.String maybeProtocol = connectionSpec.supportsTlsExtensions()? Platform.get().getSelectedProtocol(sslSocket): null;socket = sslSocket;source = Okio.buffer(Okio.source(socket));sink = Okio.buffer(Okio.sink(socket));handshake = unverifiedHandshake;protocol = maybeProtocol != null? Protocol.get(maybeProtocol): Protocol.HTTP_1_1;success = true;} catch (AssertionError e) {if (Util.isAndroidGetsocknameError(e)) throw new IOException(e);throw e;} finally {if (sslSocket != null) {Platform.get().afterHandshake(sslSocket);}if (!success) {closeQuietly(sslSocket);}}}可以看到,在創建了SSLSocket之后,總是會再通過平臺相關的接口設置SNI信息。具體對于Android而言,是AndroidPlatform.configureTlsExtensions():
@Override public void configureTlsExtensions(SSLSocket sslSocket, String hostname, List<Protocol> protocols) {// Enable SNI and session tickets.if (hostname != null) {setUseSessionTickets.invokeOptionalWithoutCheckedException(sslSocket, true);setHostname.invokeOptionalWithoutCheckedException(sslSocket, hostname);}// Enable ALPN.if (setAlpnProtocols != null && setAlpnProtocols.isSupported(sslSocket)) {Object[] parameters = {concatLengthPrefixed(protocols)};setAlpnProtocols.invokeWithoutCheckedException(sslSocket, parameters);}}可見,前面的解法并不可行。在SSLSocket創建期間設置的SNI信息,總是會由于SNI的再次設置而被沖掉,而后一次SNI信息來源則是URL。
HTTPS (含SNI) 解法二
只定制 SSLSocketFactory 的方法,看起來是比較難以達成目的了,有人就想通過更深層的定制,即同時自定義SSLSocket來實現,如GitHub中的 某項目。
但這種方法的問題更嚴重。支持SSL擴展的許多接口,都不是標準的SSLSocket接口,比如用于支持SNI的setHostname()接口,用于支持ALPN的setAlpnProtocols() 和 getAlpnSelectedProtocol() 接口等。這樣的接口還會隨著SSL/TLS協議的發展而不斷增加。許多網路庫,如OkHttp,在調用這些接口時主要通過反射完成。而在自己定義SSLSocket實現的時候,很容易遺漏掉這些接口的實現,進而折損掉某些系統本身支持的SSL擴展。
接入HttpDns的更好方法
前面遇到的那些問題,主要都是由于替換URL中的域名為IP地址發起請求時,URL中域名信息丟失,而URL中的域名在網絡庫的多個地方被用到而引起。接入HttpDns的更好方法是,不要替換請求的URL中的域名部分,只在需要Dns的時候,才讓HttpDns登場。
具體而言,是使用那些可以定制Dns邏輯的網絡庫,比如OkHttp,或者 我們在Chromium的網絡庫基礎上做的庫,實現域名解析的接口,并在該接口的實現中通過HttpDns模塊來執行域名解析。這樣就不會對網絡庫造成那么多未知的沖擊。
如:
private static class MyDns implements Dns {@Overridepublic List<InetAddress> lookup(String hostname) throws UnknownHostException {List<String> strIps = HttpDns.getInstance().getIpByHost(hostname);List<InetAddress> ipList;if (strIps != null && strIps.size() > 0) {ipList = new ArrayList<>();for (String ip : strIps) {ipList.add(InetAddress.getByName(ip));}} else {ipList = Dns.SYSTEM.lookup(hostname);}return ipList;}}private OkHttp3Utils() {okhttp3.OkHttpClient.Builder builder = new okhttp3.OkHttpClient.Builder();builder.dns(new MyDns());mOkHttpClient = builder.build();}這種方法既簡單又副作用小。
總結
以上是生活随笔為你收集整理的Android端打开HttpDns的正确姿势的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 虹桥火车站的卫生间
- 下一篇: Android平台Chromium ne