博客主頁java
本文主要介紹HTTPS(含SNI)業務場景下在Android端實現「IP直連」的通用解決方案。若是以OkHttp做爲網絡開發框架,因爲OkHttp提供了自定義DNS服務接口能夠優雅地實現IP直連。其方案相比通用方案更加簡單且通用性更強,推薦您參考HttpDns+OkHttp最佳實踐接入HttpDns。算法
發送HTTPS請求首先要進行SSL/TLS握手,握手過程大體以下:segmentfault
上述過程當中,和HTTPDNS有關的是第3步,客戶端須要驗證服務端下發的證書,驗證過程有如下兩個要點:瀏覽器
若是上述兩點都校驗經過,就證實當前的服務端是可信任的,不然就是不可信任,應當中斷當前鏈接。服務器
當客戶端使用HTTPDNS解析域名時,請求URL中的host會被替換成HTTPDNS解析出來的IP,因此在證書驗證的第2步,會出現domain不匹配的狀況,致使SSL/TLS握手不成功。網絡
SNI(Server Name Indication)是爲了解決一個服務器使用多個域名和證書的SSL/TLS擴展。它的工做原理以下:session
目前,大多數操做系統和瀏覽器都已經很好地支持SNI擴展,OpenSSL 0.9.8也已經內置這一功能。併發
上述過程當中,當客戶端使用HTTPDNS解析域名時,請求URL中的host會被替換成HTTPDNS解析出來的IP,致使服務器獲取到的域名爲解析後的IP,沒法找到匹配的證書,只能返回默認的證書或者不返回,因此會出現SSL/TLS握手不成功的錯誤。app
好比當你須要經過HTTPS訪問CDN資源時,CDN的站點每每服務了不少的域名,因此須要經過SNI指定具體的域名證書進行通訊。
針對「domain不匹配」問題,能夠採用以下方案解決:hook證書校驗過程當中第2步,將IP直接替換成原來的域名,再執行證書驗證。框架
【注意】基於該方案發起網絡請求,若報出SSL校驗錯誤,Android系統報錯System.err: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,請檢查應用場景是否爲SNI(單IP多HTTPS域名)。
此示例針對HttpURLConnection接口。
try { String url = "https://140.205.160.59/?sprefer=sypc00"; HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection(); connection.setRequestProperty("Host", "m.taobao.com"); 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); return false; } }); connection.connect(); } catch (Exception e) { e.printStackTrace(); } finally { }
定製SSLSocketFactory,在createSocket時替換爲HTTPDNS的IP,並進行SNI/HostNameVerify配置。
public class HttpDnsTLSSniSocketFactory extends SSLSocketFactory { private final String TAG = HttpDnsTLSSniSocketFactory.class.getSimpleName(); HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); private HttpsURLConnection conn; public HttpDnsTLSSniSocketFactory(HttpsURLConnection conn) { this.conn = conn; } @Override public Socket createSocket() throws IOException { return null; } @Override public Socket createSocket(String host, int port) throws IOException, UnknownHostException { return null; } @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException { return null; } @Override public Socket createSocket(InetAddress host, int port) throws IOException { return null; } @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return null; } // TLS layer @Override public String[] getDefaultCipherSuites() { return new String[0]; } @Override public String[] getSupportedCipherSuites() { return new String[0]; } @Override public 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 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) { 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 certificate SSLSession 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; } }
對於須要設置SNI的站點,一般須要重定向請求,下面給出了重定向請求的處理方法。
public void recursiveRequest(String path, String reffer) { URL url = null; try { url = new URL(path); conn = (HttpsURLConnection) url.openConnection(); String 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); HttpDnsTLSSniSocketFactory sslSocketFactory = new HttpDnsTLSSniSocketFactory(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信息一致後,您能夠在回調方法中將待驗證域名替換爲原來的真實域名進行驗證。 * */ @Override public 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 block if (needRedirect(code)) { //臨時重定向和永久重定向location的大小寫有區分 String location = conn.getHeaderField("Location"); if (location == null) { location = conn.getHeaderField("location"); } if (!(location.startsWith("http://") || location .startsWith("https://"))) { //某些時候會省略host,只返回後面的path,因此須要補全url URL 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; }
若是個人文章對您有幫助,不妨點個贊鼓勵一下(^_^)