标签归档:HTTPS

HTTPS和SNI

问题:有两个站点架在同一台服务器上,并且强制HTTPS访问,地址分别为https://sub.domain.com和https://admin.sub.domain.com,HTTPS证书签名的域名分别为*.domain.com和*.sub.domain.com,使用HttpClient(4.1.1版本)去调用一个接口:

1
2
3
4
5
6
7
public class TestGet {
  public static void main(String args[]) throws Exception {
    HttpGet httpGet = new HttpGet("https://sub.domain.com/api/nodes");
    HttpClient httpClient = new DefaultHttpClient();
    httpClient.execute(httpGet);
  }
}

返回 javax.net.ssl.SSLException异常:

Exception in thread "main" javax.net.ssl.SSLException: hostname in certificate didn't match:  != <*.sub.domain.com> OR <*.sub.domain.com>
    at org.apache.http.conn.ssl.AbstractVerifier.verify(AbstractVerifier.java:231)
    at org.apache.http.conn.ssl.BrowserCompatHostnameVerifier.verify(BrowserCompatHostnameVerifier.java:54)
    at org.apache.http.conn.ssl.AbstractVerifier.verify(AbstractVerifier.java:152)
    at org.apache.http.conn.ssl.AbstractVerifier.verify(AbstractVerifier.java:133)
    at org.apache.http.conn.ssl.SSLSocketFactory.verifyHostname(SSLSocketFactory.java:559)
    at org.apache.http.conn.ssl.SSLSocketFactory.connectSocket(SSLSocketFactory.java:534)
    ……

问题分析:从异常信息来看,显然是SSL连接握手阶段出现证书不匹配的问题,客户端请求的是sub.domain.com域名,服务器返回了*.sub.domain.com的证书。但是浏览器访问又正常,这是为何?

解决方法:故事要从IPv4的设计开始说起,设计者认为32位够用了,后续的上层协议设计者也纷纷假设一个IP提供一个服务的前提,比如HTTP/1.0、SSL等,一个大坑就这样被埋下。从90年代中期开始,互联网发展太快,人们开始意识到IP资源越来越稀缺,于是着手设计IPv6,但是从IPv4到IPv6需要一个很长的过渡期,远远大于IPv4地址耗尽的时间。于是一些IP复用的方案出现了,CIDR和NAT技术能在一定程度上缓和IPv4资源稀缺的问题,互联网协议也适时进行了升级,比如HTTP,在HTTP/1.1版本中加入Host头,这个头部在虚拟主机(Virtual Host)中非常重要,它支持不同站点架在一个IP上,客户端通过Host头告知服务器需要访问的站点。但是Host头无法解决HTTPS场景中客户端与虚拟主机(多站点共享同一个IP)之间加密连接的建立,因为HTTPS依赖的SSL/TLS协议并没有同步跟进,SSL握手阶段客户端向服务器端发送的信息中未包含Host,所以服务器端也就没法返回正确的HTTPS证书了。这样的背景下,解决HTTPS虚拟主机的方法主要有:

1. 绑定不同的端口。为不同的虚拟主机绑定不同的HTTPS端口(默认端口443)。缺点显而易见,每次访问都需要显式指定端口号。
2. 绑定不同的IP。前面说过,IPv4资源越来越稀缺,而且这种方法也违背了虚拟主机的初衷。
3. 购买泛域名SSL证书(Wildcard Certificate)。
4. 购买多域名SSL证书(Multi Domain Certificate)。

这些方法都只是绕过限制,要根本解决问题还是得修改SSL/TLS协议,所以SNI(Server Name Indication)就适时被提了出来,它扩展了TLS协议(SSL 3.0不支持,在TLS 1.0以后支持,RFC4366RFC6606),在客户端请求的CLIENTHELLO阶段加入Host信息,告知服务器端要与哪个主机建立加密连接。
SNI

图片来源

实际上,SNI需要通信双方(服务端、客户端)都支持,根据Wiki上的总结,目前一些常见浏览器、服务器软件、类库的支持情况如下:

浏览器:

IE 7+
Mozilla Firefox 2.0+
Opera 8.0+
Google Chrome 5.0.342.1+
Safari 3.0+

注:这里的支持度还跟操作系统相关,比如Windows XP上所有IE均不支持,Chrome在不同操作系统下开始支持的版本也不同。

服务器软件:

Apache 2.2.12+
Nginx(依赖支持sni的openssl库)
Apache Traffic Server 3.2.0+
HAProxy 1.5+

类库:

OpenSSL: 0.9.8f(compiled in with config option '--enable-tlsext'), 0.9.8j+
libcurl / cURL 7.18.1+
Python 3.2
Qt 4.8
Oracle Java 7 JSSE
Apache HttpComponents 4.3.2
wget 1.14
Android 4.2 (Jellybean MR1)
Go (client and server)

Java是在JDK1.7里才支持SNI的,因此要在Java应用里使用,前提就是将OpenJDK或者Oracle JDK升到1.7,如果同时使用HttpClient进行HTTP接口调用,那么还必须将HttpClient版本升到4.3.2及以后,这个JIRA单描述了如何支持SNI(代码设计层面)的前因后果。

回到开头的异常问题,可以通过以下步骤修复:

1. 升级JDK至少到1.7。
2. 升级HttpClient至少到4.3.2。
3. 更新少量代码如下:

1
2
3
4
5
6
7
8
public class TestGet {
  public static void main(String args[]) throws Exception {
    HttpGet httpGet = new HttpGet("https://sub.domain.com/api/nodes");
    //HttpClient httpClient = new DefaultHttpClient();
    CloseableHttpClient httpClient = HttpClients.createDefault();
    httpClient.execute(httpGet);
  }
}

--EOF--

WebSocket via nginx

WebSocket的握手流程[1]如下:
浏览器请求:

1
2
3
4
5
6
7
8
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

服务器响应:

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

细节不展开了,只需关注浏览器和服务器之间如何通过Upgrade和Connection header进行协商,将一个HTTP连接升级为WebSocket。

nginx 1.3版本以后开始支持WebSocket的反向代理。因为WebSocket的握手主要是通过标准HTTP请求和响应来实现的,因此对于反向代理服务器,nginx在7层只要正确处理好WebSocket协议的握手流程,使得本来在tcp连接上跑HTTP协议的,现在用来跑WebSocket,只要握手完成剩下的交互就是tcp层面的事,跟HTTP协议没有关系了。注意,如果nginx版本小于1.3,对WebSocket进行反向代理时会出现“WebSocket is closed before the connection is established.”的警告。

nginx支持WebSocket需进行以下配置(官方文档):

1
2
3
4
5
6
location /chat/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

HTTP 1.1的长连接是WebSocket协商的基础,另外因为Upgrade和Connection header在HTTP协议中都是hop-by-hop header,因此在反向代理的时候需要把这两个header回填,发送到服务器端。从全局来看,就是客户端(浏览器)同nginx之间建立一条tcp长连接,nginx和后端服务器之间建立一条(或多条,视upstream的server节点个数)tcp长连接,这些长连接都是用来传递WebSocket数据帧。

以下是一个真实例子,使用场景来为:
Openstack组件中有个nova-novncproxy服务,支持用户通过浏览器以VNC的方式登陆虚拟机。现在有一个类似域名为https://console.ustack.com的Web管理平台,在VNC登陆虚拟机页面嵌入noVNC地址。这里的问题是,Web管理平台是https的,如果noVNC地址是http,那么高版本浏览器为了安全考虑都会屏蔽noVNC页面,理由是https页面内嵌了http页面;如果给noVNC页面也加个https支持,那就有点浪费了,给IP地址买张证书不划算,给域名买张证书不划算的同时还浪费一个域名。所以最好的方式就是在Web管理平台的前端nginx机器上为noVNC做个反向代理,用户看到的都是Web管理平台的域名,这样在交互上也显得统一。结合上文描述的nginx支持WebSocket的配置,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#web管理平台
upstream web {
  server 127.0.0.1:8080;
  keepalive 80;
}
 
#noVNC
upstream vnc {
  server 111.26.23.10:6080;
  keepalive 80;
}
 
 
server {
  listen 443;
  server_name console.ustack.com;
  ssl on;
  ssl_certificate /etc/nginx/ssl/server.crt;
  ssl_certificate_key /etc/nginx/ssl/server.key;
 
  location / {
    proxy_pass http://web;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header REMOTE-HOST $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
  }
 
  location /vnc {
    rewrite ^/vnc/(.*) /$1 break;
    proxy_pass http://vnc;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header REMOTE-HOST $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
  }
 
  location /websockify {
    proxy_pass http://vnc;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header REMOTE-HOST $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_read_timeout 86400;
  }
}

其中,/websockify location就是用来反向代理WebSocket请求,proxy_read_timeout参数是为了避免长时间没有数据收发时连接被nginx关闭。

[1] WebSocket. http://en.wikipedia.org/wiki/WebSocket.

--EOF--