Android端HTTPS(含SNI)业务场景"IP直连"方案说明

本文档介绍AndroidHTTPS(含SNI)业务场景“IP直连”的通用解决方案。

概述

HTTPS是一种通过计算机网络进行安全通信的传输协议,经由HTTP进行通信,利用SSL/TLS建立全信道,加密数据包。HTTPS使用的主要目的是提供对网站服务器的身份认证,同时保护交换数据的隐私与完整性,TLS是传输层加密协议,前身是SSL协议。HTTPS下有两种业务场景普通场景和SNI场景,SNI(Server Name Indication)是用来改善服务器与客户端SSL(Secure Socket Layer)和TLS(Transport Layer Security)的扩展。主要解决一台服务器能够提供多个域名服务的情况。

  • 普通场景

普通场景使用“IP直连”对开发者来说很方便,直接将请求URL中的Host替换成IP,在执行证书验证时将IP再替换成原来的域名即可。

  • SNI场景

SNI(单IPHTTPS证书)场景下,针对HttpsURLConnection接口,开发者需要定制SSLSocketFactory,在createSocket时通过阿里云公共DNS Android SDK提供的API替换为域名解析后的IP后,并需要对 SNI/HostNameVerify进行配置。

重要
  1. 由于OKHttp提供了自定义DNS服务接口可以优雅地实现IP直连。如果您是Android开发者,并且以OKHttp作为网络开发框架。其方案相比通用方案更加简单且通用性更强,推荐您参考AndroidOkHttp框架场景下接入阿里云公共DNS Android SDK最佳实践方案接入阿里云公共DNS SDK。

  1. 如果用户在Server端使用了CDN服务或者未来会使用CDN服务,请参考HTTPSSNI场景的解决方案使用。

实践方案

  • HTTPS下普通场景解决方案

针对“domain不匹配”的问题,可以采用hook证书校验的解决方案,将IP直接替换成原来的域名,再执行证 书验证。

此示例针对HttpURLConnection

try {
      String url = "https://124.239.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.
       *
       * 使用阿里云公共DNS Android SDK后URL里设置的hostname不是远程的主机名(如:m.taobao.com),与证书颁发的域不匹配,
       * Android HttpsURLConnection提供了回调接口让用户来处理这种定制化场景。
       * 在确认阿里云公共DNS Android SDK返回的源站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();
     } finally { 
   }
重要

基于该方案发起网络请求,若报出SSL校验错误,比如Android系统报错System.err: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.,请检查应用场景是否为SNI(单IPHTTPS域名)。

  • HTTPSSNI场景解决方案

  1. 针对HttpsURLConnection接口提供了在SNI场景使用的示例代码,完整代码请参考Demo示例工程源码。

  2. 定制SSLSocketFactory,在创建createSocket时将域名替换成域名解析后的IP,并进行SNI/ HostNameVerfy配置。

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 = dnsResolver.getIPV4ByHost(url.getHost();
          if (ip != null) {
               // 通过阿里云公共DNS Android SDK获取IP成功,进行URL替换和HOST头设置
               Log.d(TAG, "get IP: " + ip + " for host: " + url.getHost() + "from pdns resolver success!");
               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.
             *
             * 使用阿里云公共DNS Android SDK后URL里设置的hostname不是远程的主机名(如:m.taobao.com),与证书颁发的域不匹配,
             * Android HttpsURLConnection提供了回调接口让用户来处理这种定制化场景。
             * 在确认阿里云公共DNS Android SDK返回的源站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();
        }
    }
}
重要

  1. 当前接入阿里云公共DNS Android SDK文档只针对结合HTTPS的普通场景和SNI场景下使用。

  2. 如何使用阿里云公共DNS Android SDK的域名解析服务和接入阿里云公共DNS Android SDK的自身问题,请先查看 Android SDK开发指南。

  3. 开发者在HTTPSSNI场景接入阿里云公共DNS Android SDK完整代码请参考Demo示例工程源码。