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();// 同步接口获取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 {}
主要思路即是自定义证书验证的逻辑。HostnameVerifier
的 verify()
传回来的域名是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的正确姿势相关推荐
- 新媒体人打开 ChatGPT 的正确姿势
最近互联网讨论最多的话题,莫过于 ChatGPT. 虽然它是一个聊天机器人,但能模拟人的语气和用户聊天,并且通过不断地学习.理解进一步优化回答. 自去年11月发布以来,ChatGPT 便在全球引起了一 ...
- Android端WEEX + HTTPDNS 最佳实践
由于WebView并未暴露处设置DNS的接口,因而在WebView场景下使用HttpDns存在很多无法限制,但如果接入WEEX,则可以较好地植入HTTPDNS,本文主要介绍在WEEX场景下接入HTTP ...
- 再见虚拟机!聊聊 PC 端运行 Docker 的正确姿势!
点击上方"AirPython",选择"加为星标" 第一时间关注 Python 原创干货! 1. 前言 大家好,我是安果! 在相当长的一段时间里,PC 端要使用 ...
- Android Studio导入工程的正确姿势
为什么80%的码农都做不了架构师?>>> 如果你有很好的网络环境 好的网络环境,这里不是指:我家网速带宽100M,电信的光纤接入. 而是:能翻墙.因为如果本机的gradle和将 ...
- 9012年,论数字技术核聚变下打开中台的正确姿势
9012年了,不搞点数字化转型升级,似乎KPI没有亮点.但你说,服务拆分成几百个,遇到问题反而不好排查,还搞个锤子微服务:你又说,Hadoop那么难用,三大豪门(Cloudera.Hortonwork ...
- 利用 a 标签 实现 下载图片(不是打开)的正确姿势
1. 场景再现 当 a 标签中添加了 download 属性,想要实现下载图片时: 点击 a 链接,没有进行下载,而是在 当前页面 打开了图片 2. 原因 <a> 有 download 属 ...
- 打开github的正确姿势
打开github的方法: 转载自:https://blog.csdn.net/cjqh_hao/article/details/106736141 修改host文件添加github的IP地址,host ...
- 打开PhotoshopTea的正确姿势
PhotoshopTea不希望自己的文章像情感类文章那样,偶尔被读到,亦或激发一下肾上腺. PhotoshopTea实质上是一本手册,在需要的时候去查询.去搜索,然后逐字逐句地去阅读.去实践. 首先打 ...
- 深度好文 | 战“疫”上云正当时:打开云计算的正确姿势
作者 | 马超 责编 | Carol 封图 | CSDN 付费下载于视觉中国 4月29日,谷歌的母公司Alphabet正式发布了2020年第一季度财报,报告显示,Alphabet比去年同期的363.3 ...
最新文章
- Linux 问题分析,性能优化
- DbLookUpCombobox的使用方法
- 在VisualStadio2015上使用EF6.0建立MySql数据库
- html里面怎么ul加高度,div里面嵌套了ul,为什么div的高度小于ul高度
- java 内存指针_java内存模型详解
- java前台计算date差_js前台计算两个日期的间隔时间
- Android_Bitmap_图片的二次采样并生成缩略图
- 【Nginx探究系列二】Nginx配置篇之客户Nginx白名单访问配置
- 【安全系列】IPSEC ×××之安全基础篇
- Dynamics AX2012 保留上一次操作记录
- backtracking line Serach
- 香港云服务器选阿里云好还是腾讯云好?
- 树莓派 PWM 控制步进
- Enriched Feature Guided Refinement Network for Object Detection(面向目标检测的丰富特征引导细化网络)
- CSS3 设置模糊背景图片
- ANDROID 开源库
- vue项目通讯录_vue 自定义组件实现通讯录功能
- 2021江苏大学生编程大赛I题(省赛试水)
- 【玩转yolov5】请看代码之参数管理及学习率调整
- 计算机程序设计员国家职业资格三级操作技能考核试卷-带实际操作答案