1. 程式人生 > >Android端開啟HttpDns的正確姿勢

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();
        // 同步介面獲取IP
        String 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資訊一致後,您可以在回撥方法中將待驗證域名替換為原來的真實域名進行驗證。
                 *
                 */
                @Override
                public boolean verify(String hostname, SSLSession session) {
                    return HttpsURLConnection.getDefaultHostnameVerifier().verify(originHostname, session);
                }
            });
            connection.connect();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
        }

主要思路即是自定義證書驗證的邏輯。HostnameVerifierverify() 傳回來的域名是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;
    }
    @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;
    }
}

HTTPS請求發起過程如下:

    public void recursiveRequest(String path, String reffer) {
        URL url = null;
        try {
            url = new URL(path);
            conn = (HttpsURLConnection) url.openConnection();
            // 同步介面獲取IP
            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);
            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資訊一致後,您可以在回撥方法中將待驗證域名替換為原來的真實域名進行驗證。
                 *
                 */
                @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;
    }

但這種解法是否真的可行呢?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 {

        @Override
        public 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();
    }

這種方法既簡單又副作用小。