感谢HeapDump社区送的新年礼物
通过一个小案例,聊聊不同角色对于同一个问题的不同见地,立场不同,思考方式不同,答案自然不同,对于决策者,如何更好的“以道御术”。
问题背景
有个新部署的项目,客户要使用https,希望我们支持一下,我心想这个在nginx端配置就可以,后台代码不需要任何改造,立马就答应了。
第二天,客户将https证书提供过来,我协调运维2分钟配置完成,似乎很美好,稳妥起见我还是决定先测试一下。
1.访问https开头的首页可以正常打开;
2.接着进行登录,点击登录按钮以后页面没反应;
3.F12打开控制台,重复步骤2,可以看到登录正常,但是登录以后返回的重定向地址请求超时,如下图所示;
长时间pending状态
最终连接超时 ERR_CONNECTION_TIMED_OUT
初步分析
其实打开控制台那一刻原因就很明确了,重定向返回的地址scheme变成了http,形如http://www.weixin.com,接着浏览器根据www.weixin.com:80建立连接,而www.weixin.com这个域名所在nginx并没有开启80端口,最终连接超时,浏览器就会报ERR_CONNECTION_TIMED_OUT。
为什么成了http呢?
后端程序返回重定向地址时会调用javax.servlet.ServletRequest对象的getScheme方法获取scheme然后拼接形成最终的重定向地址,比如req.getScheme()+"://"+uri。
getScheme这是一个接口方法,具体的实现由Servlet容器实现,比如Tomcat,Jetty等。
/** * Defines an object to provide client request information to a servlet. The servlet container creates a * <code>ServletRequest</code> object and passes it as an argument to the servlet's <code>service</code> method. * * <p> * A <code>ServletRequest</code> object provides data including parameter name and values, attributes, and an input * stream. Interfaces that extend <code>ServletRequest</code> can provide additional protocol-specific data (for * example, HTTP data is provided by {@link javax.servlet.http.HttpServletRequest}. * * @author Various * * @see javax.servlet.http.HttpServletRequest * */ public interface ServletRequest { /** * Returns the name of the scheme used to make this request, for example, <code>http</code>, <code>https</code>, or * <code>ftp</code>. Different schemes have different rules for constructing URLs, as noted in RFC 1738. * * @return a <code>String</code> containing the name of the scheme used to make this request */ public String getScheme(); }
至于Servlet容器背后的实现细节在此不做深究,最直观的答案是req.getSheme取到的sheme不对。
初步解决
当我跟运维同学说了这个情况之后他立马就给出了两种解决方案,我们依次来看一下。
方案一:在nginx侧增加响应头 Strict-Transport-Security
add_header Strict-Transport-Security "max-age=86400";
看下rfc文档对于它的描述:
HTTP Strict Transport Security (HSTS) Abstract This specification defines a mechanism enabling web sites to declare themselves accessible only via secure connections and/or for users to be able to direct their user agent(s) to interact with given sites only over secure connections. This overall policy is referred to as HTTP Strict Transport Security (HSTS). The policy is declared by web sites via the Strict-Transport-Security HTTP response header field and/or by other means, such as user agent configuration, for example.
https://datatracker.ietf.org/doc/rfc6797/
HTTP Strict-Transport-Security响应标头(通常缩写为HSTS)通知浏览器该站点只能使用 HTTPS 访问,并且将来使用 HTTP 访问它的任何尝试都应自动转换为 HTTPS。
简单来讲就是告诉浏览器我的网站只支持https,下次遇到用户访问我的网站时强制使用https访问。
看看效果如何
浏览器对http的请求重新做了一次307 Internal Redirect,将scheme变成了https,对照下面流程图帮助理解。
看起来似乎很完美,难道真就无懈可击了吗,当然不是,背后是通过HSTS协议+浏览器配合完成,所以这个方案有一个绕不过去的问题就是浏览器兼容性,看下developer.mozilla.org对于浏览器兼容性的统计。
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
这么一来,这个方案的普适性还是不够强,需要探索更通用的方案。
方案二:使用nginx proxy_redirect http:// https://
proxy_redirect http:// https://
看下nginx官方文档的描述:
Syntax: proxy_redirect default; proxy_redirect off; proxy_redirect redirect replacement; Default: proxy_redirect default; Context: http, server, location Sets the text that should be changed in the “Location” and “Refresh” header fields of a proxied server response
简单来讲就是告诉nginx在返回响应时,如果响应头中携带了Location或者Refresh,就根据指定的规则对URL进行改写。
比如上面提到的proxy_redirect http:// https://,会在返回响应时将URL中的http替换为https,从而解决我们遇到的问题,同样贴个图帮助理解。
这个方案没有前面提到的浏览器兼容性问题,但是存在场景兼容性问题,怎么讲呢?proxy_redirect只适用于原生重定向这种场景,如果是非原生重定向场景这个指令其实是没有功效的,接下来举个我接触过的真实案例。
笔者之前参与过异地双活业务,简单来讲就是将用户流量划分到两个不同的机房来分担压力、容灾,流量划分原则是通过用户id取模,比如userid%2,这里面就牵扯到一个域名下发的问题,请看下图(为了保持简单,逻辑有删减):
上图中的2.2会在响应中写入该用户的归属域名,形如:
{ "success":false, "code":"cross_domain" "target_domain":"B.xxx.com" }
设备端会在接收响应时判断是否需要切换域名重新登录,判断的依据就是根据响应报文中的success和code,最终的新域名是响应报文中的target_domain字段,这个场景中也是实现了重定向的效果,但是却没有用http中301+location那种方式(千万别问为什么不用301+location),所以proxy_redirect自然也就用不上了。
那怎么办呢,接下来看我的终极解决办法。
终极解决
其实这个问题的始作俑者还是后端服务获取不到真实的scheme导致,所以终极解决办法就是纠正源头,至于源头为什么不对呢,相信大伙都猜到了,因为请求链路上多了一层反向代理导致,隐藏了真实的请求信息,比如ip、scheme等,为了方便理解依然贴个图:
浏览器发出的原始请求是https://weixin.com:443,nginx作为反向代理,内部将请求转发到http://192.168.1.1/2:8001,这个过程中scheme由https变成了http,端口由443变成了8081,所以处在nginx后方的业务服务想获取最原始的请求信息似乎不可能。
现实当然是没有那么糟糕,反向代理并不是不讲武德的流氓软件(前段时间通过一个非官方网站下载软件,软件倒是安装上了,但是电脑多了几十个垃圾软件),接下来简单了解下反向代理的武德是什么。
rfc文档专门针对这种经过代理导致原始信息丢失的情况增加了相应的扩展头,比如常见的X-Forwarded-For(获取原始请求IP)、X-Forwarded-Proto(获取原始请求协议)等,在反向代理软件中也已经得到支持,所以只要正确的配置反向代理,将扩展头往后传递,处于它后方的Servlet容器就可以正确识别相应信息。
https://datatracker.ietf.org/doc/html/rfc7239#section-5.3
Forwarded HTTP Extension Abstract This document defines an HTTP extension header field that allows proxy components to disclose information lost in the proxying process, for example, the originating IP address of a request or IP address of the proxy on the user-agent-facing interface. In a path of proxying components, this makes it possible to arrange it so that each subsequent component will have access to, for example, all IP addresses used in the chain of proxied HTTP requests. This document also specifies guidelines for a proxy administrator to anonymize the origin of a request.
具体到这个案例中分两步:
1.nginx中增加X-Forwarded-Proto
proxy_set_header X-Forwarded-Proto $scheme;
2.Servert容器中开启Forward协议的解析器,以Jetty为例
class ForwardHeadersCustomizer implements JettyServerCustomizer { @Override public void customize(Server server) { ForwardedRequestCustomizer customizer = new ForwardedRequestCustomizer(); for (Connector connector : server.getConnectors()) { for (ConnectionFactory connectionFactory : connector.getConnectionFactories()) { if (connectionFactory instanceof HttpConfiguration.ConnectionFactory) { ((HttpConfiguration.ConnectionFactory) connectionFactory).getHttpConfiguration() .addCustomizer(customizer); } } } } }
经过这两步的调整,数据的源头已经被纠正,对比之前两个方案它的通用性更强,没有浏览器兼容性问题,没有使用场景的限制。
道法术
道法术出自老子《道德经》,道,是规则、自然法则,上乘。法,是方法、法理,中乘。术,是行为、方式,下乘。“以道御术”即以道义来承载智术,悟道比修炼法术更高一筹。“术”要符合“法”,“法”要基于“道”,道法术三者兼备才能做出最好的策略。
以上内容摘自百度百科
回顾前面的三种“术”,都是具体的方法、行为,它们都遵循的道是“返回正确的URL给浏览器”,我们在思考问题时应该以谁为出发点呢,这个问题没有标准答案,简单说说我的拙见吧。
如果一开始就陷入“术”,也就是追求具体的解决办法,会让思路变得狭窄,在本次案例中,起初我的关注点一直在纠结为什么业务服务拿到的sheme是不正确的,所以眼光只是看到了纠正源头这个点,过多的关注细节,或许这是开发人员的固有思维?这种固有思维容易让我陷入死胡同,需要适当的跳出来,站高一线的思考问题,这样才能收获更多的解法,充分对比,找到更优。
运维同学提供的两种方案确实让我开了眼,他把后端应用完全当成一个黑盒,输出不对那就借助外力再修改一次,只要结果是正确的就行,似乎有点“以道御术”的味道了。
讲一个我遇到的例子,在我刚参加工作没几天的时候,研发经理给我分配了一个疑难bug让我处理,我当时就蒙了,简单的还没弄明白呢,怎么一上来就玩高难度了,研发经理跟我说:“不要有压力,之所以让你试试是因为你对这个问题一无所知,不会有固化思维,不用考虑太多历史包袱,你可以用一切办法去解决它。“
最终结果确实是好的,当然中途也被pass过好多方案,事后反观最终方案,确实没什么难度,只是其他人没想到。
所以结果是什么呢?哈哈,自己悟吧。
推荐阅读
Strict-Transport-Security Browser Compatibility