全部產品
Search
文件中心

HTTPDNS:Android端HTTPS(含SNI)業務情境:IP直連方案

更新時間:Jul 13, 2024

重要

當前最佳實務文檔只針對結合使用時,如何使用HTTPDNS解析出的IP,關於HTTPDNS本身的解析服務,請先查看Android SDK開發指南

背景說明

本文主要介紹HTTPS(含SNI)業務情境下在Android端實現“IP直連”的通用解決方案。如果您是Android開發人員,並且以OkHttp作為網路開發架構,由於OkHttp提供了自訂DNS服務介面可以優雅地實現IP直連。其方案相比通用方案更加簡單且通用性更強,推薦您參考Android端HTTPDNS+OkHttp接入指南接入HTTPDNS。

HTTPS

發送HTTPS請求首先要進行SSL/TLS握手,握手過程大致如下:

  1. 用戶端發起握手請求,攜帶隨機數、支援演算法列表等參數。

  2. 服務端收到請求,選擇合適的演算法,下發密鑰憑證和隨機數。

  3. 用戶端對服務端認證進行校正,並發送隨機數資訊,該資訊使用公開金鑰加密。

  4. 服務端通過私密金鑰擷取隨機數資訊。

  5. 雙方根據以上互動的資訊產生session ticket,用作該串連後續資料轉送的加密金鑰。

上述過程中,和HTTPDNS有關的是第3步,用戶端需要驗證服務端下發的認證,驗證過程有以下兩個要點:

  1. 用戶端用本地儲存的根憑證解開憑證鏈結,確認服務端下發的認證是由可信任的機構頒發的。

  2. 用戶端需要檢查認證的domain域和擴充域,看是否包含本次請求的host。

如果上述兩點都校正通過,就證明當前的服務端是可信任的,否則就是不可信任,應當中斷當前串連。

當用戶端使用HTTPDNS解析網域名稱時,請求URL中的host會被替換成HTTPDNS解析出來的IP,所以在認證驗證的第2步,會出現domain不匹配的情況,導致SSL/TLS握手不成功。

SNI

SNI(Server Name Indication)是為瞭解決一個伺服器使用多個網域名稱和認證的SSL/TLS擴充。它的工作原理如下:

  1. 在串連到伺服器建立SSL連結之前先發送要訪問網站的網域名稱(Hostname)。

  2. 伺服器根據這個網域名稱返回一個合適的認證。

目前,大多數作業系統和瀏覽器都已經很好地支援SNI擴充,OpenSSL 0.9.8也已經內建這一功能。

上述過程中,當用戶端使用HTTPDNS解析網域名稱時,請求URL中的host會被替換成HTTPDNS解析出來的IP,導致伺服器擷取到的網域名稱為解析後的IP,無法找到匹配的認證,只能返回預設的認證或者不返回,所以會出現SSL/TLS握手不成功的錯誤。

說明

比如當你需要通過HTTPS訪問CDN資源時,CDN的網站服務了很多的網域名稱,所以需要通過SNI指定具體的網域名稱認證進行通訊。

HTTPS情境(非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.XX.XX/?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 {
}

HTTPS情境(SNI)解決方案

HTTPDNS Android Demo中針對HttpsURLConnection介面,提供了在SNI業務情境下使用HTTPDNS的範例程式碼。

定製SSLSocketFactory,在createSocket時替換為HTTPDNS的IP,並進行SNI/HostNameVerify配置。

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;
    }
}

對於需要設定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);
        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;
}