什么是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 {}

主要思路即是自定义证书验证的逻辑。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;}@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的正确姿势相关推荐

  1. 新媒体人打开 ChatGPT 的正确姿势

    最近互联网讨论最多的话题,莫过于 ChatGPT. 虽然它是一个聊天机器人,但能模拟人的语气和用户聊天,并且通过不断地学习.理解进一步优化回答. 自去年11月发布以来,ChatGPT 便在全球引起了一 ...

  2. Android端WEEX + HTTPDNS 最佳实践

    由于WebView并未暴露处设置DNS的接口,因而在WebView场景下使用HttpDns存在很多无法限制,但如果接入WEEX,则可以较好地植入HTTPDNS,本文主要介绍在WEEX场景下接入HTTP ...

  3. 再见虚拟机!聊聊 PC 端运行 Docker 的正确姿势!

    点击上方"AirPython",选择"加为星标" 第一时间关注 Python 原创干货! 1. 前言 大家好,我是安果! 在相当长的一段时间里,PC 端要使用 ...

  4. Android Studio导入工程的正确姿势

    为什么80%的码农都做不了架构师?>>>    如果你有很好的网络环境 好的网络环境,这里不是指:我家网速带宽100M,电信的光纤接入. 而是:能翻墙.因为如果本机的gradle和将 ...

  5. 9012年,论数字技术核聚变下打开中台的正确姿势

    9012年了,不搞点数字化转型升级,似乎KPI没有亮点.但你说,服务拆分成几百个,遇到问题反而不好排查,还搞个锤子微服务:你又说,Hadoop那么难用,三大豪门(Cloudera.Hortonwork ...

  6. 利用 a 标签 实现 下载图片(不是打开)的正确姿势

    1. 场景再现 当 a 标签中添加了 download 属性,想要实现下载图片时: 点击 a 链接,没有进行下载,而是在 当前页面 打开了图片 2. 原因 <a> 有 download 属 ...

  7. 打开github的正确姿势

    打开github的方法: 转载自:https://blog.csdn.net/cjqh_hao/article/details/106736141 修改host文件添加github的IP地址,host ...

  8. 打开PhotoshopTea的正确姿势

    PhotoshopTea不希望自己的文章像情感类文章那样,偶尔被读到,亦或激发一下肾上腺. PhotoshopTea实质上是一本手册,在需要的时候去查询.去搜索,然后逐字逐句地去阅读.去实践. 首先打 ...

  9. 深度好文 | 战“疫”上云正当时:打开云计算的正确姿势

    作者 | 马超 责编 | Carol 封图 | CSDN 付费下载于视觉中国 4月29日,谷歌的母公司Alphabet正式发布了2020年第一季度财报,报告显示,Alphabet比去年同期的363.3 ...

最新文章

  1. Linux 问题分析,性能优化
  2. DbLookUpCombobox的使用方法
  3. 在VisualStadio2015上使用EF6.0建立MySql数据库
  4. html里面怎么ul加高度,div里面嵌套了ul,为什么div的高度小于ul高度
  5. java 内存指针_java内存模型详解
  6. java前台计算date差_js前台计算两个日期的间隔时间
  7. Android_Bitmap_图片的二次采样并生成缩略图
  8. 【Nginx探究系列二】Nginx配置篇之客户Nginx白名单访问配置
  9. 【安全系列】IPSEC ×××之安全基础篇
  10. Dynamics AX2012 保留上一次操作记录
  11. backtracking line Serach
  12. 香港云服务器选阿里云好还是腾讯云好?
  13. 树莓派 PWM 控制步进
  14. Enriched Feature Guided Refinement Network for Object Detection(面向目标检测的丰富特征引导细化网络)
  15. CSS3 设置模糊背景图片
  16. ANDROID 开源库
  17. vue项目通讯录_vue 自定义组件实现通讯录功能
  18. 2021江苏大学生编程大赛I题(省赛试水)
  19. 【玩转yolov5】请看代码之参数管理及学习率调整
  20. 计算机程序设计员国家职业资格三级操作技能考核试卷-带实际操作答案

热门文章

  1. mysql 交集_MYSQL交集函数
  2. Spring Cloud Gateway 之获取请求体的几种方式
  3. shell字符串操作
  4. python请输入_不断提示用户输入Python
  5. 前端必知必会--JSON.stringify()犀利的第三个参数
  6. 启动php出现的错误
  7. EEPlat的元模型体系
  8. LAPM×××和php加速器
  9. fodera开机启动优化
  10. 开店攻略: 购买行为越来越冲动性