HTTPS 反向代理

元気を出さないと、ご飯もおいしくないよ

Posted by kayoch1n's blog on March 14, 2022

现在的牛马不用框架的话估计搞不出来一个能用的后台web服务吧(心虚);但是既然有轮子可以用那用也无妨。流行的框架给予了码农极大的便利能够集中精力编写业务逻辑,HTTPS什么的当然是有内建支持的啦,毕竟你已经是一个成熟的框架了,需要学会自己上HTTPS(bushi)。

无奈最近出于测试需要用docker搞了一个服务,但是没有挂载目录,也没有外接DB,数据什么的直接就在容器里,一重启就gg。为了自测HTTPS场景而且也不想要重新部署,决定不动原来的服务,用nginx搞一下HTTPS反向代理。

作为例子,本文使用了腾讯云CVM(ubuntu)作为服务器,SSL证书同样来自腾讯云。实现目标如下:

  • 在80端口启动一个没有HTTPS的httpd作为后台服务,但是不让外部访问80(可以通过腾讯云安全组限制);
  • 在443端口启动nginx反向代理,将外部请求导流到本地80端口的httpd。

拓扑关系图如下:

浏览器 <--HTTPS--> nginx(反向代理) <--HTTP--> httpd(业务)

如果没有其他网关或者代理,浏览器和nginx、nginx和httpd之间将会各自建立一个TCP连接;其中浏览器和nginx的HTTPS在TLS隧道上进行。

HTTPS 建立会话

简单描述一下建立HTTPS会话(TLS1.2)的过程,IBM有一篇文章详细描述了这个过程,同时也可以用wireshark抓包来对比观察这个过程。

Client |-------client hello----->| Server
       |                         |
       |<------server hello------|
       |<------certificate-------|
       |<--certificate  status---|
       |<--server key exchange---|(optional)
       |<---server hello done----|
       |                         |
       |---client key exchange-->|
       |----client hello done--->|(我没观测到-_-|||)
       |                         |
       |<-----encrypted data---->|
  1. 浏览器发送一些初始参数,其中包含TLS的版本(1.2)和若干个支持的密码suite,供服务器选择,每个suite都包含key exchange算法、身份验证算法和bulk exchange算法在内的若干个信息。以TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384为例,这个suite将使用ECDHE交换密钥,用RSA进行身份验证,用AES256GCM对数据流进行对称加密,以及用SHA384进行数字签名。
  2. 服务器发送已选择的密码suite以及证书。server key exchange 还会交换一段信息,通常是因为客户端无法通过证书的公钥获取足够信息来产生密钥。
  3. 浏览器发送用于产生对成加密密钥的信息。
  4. 交换密钥信息完成后,TLS隧道建立完成。浏览器和服务器在这个隧道上进行HTTP会话。

在这之后,wireshark只能看到加密之后的、看似杂乱无章TCP数据,无法看到任何有意义的明文。有意思的是,chrome控制台和fiddler可以抓到解密之后的明文,不过这两个的原理不同:fiddler会在本地启动一个代理,要求PC信任fiddler的证书;在建立HTTPS之前还会有一个 HTTP CONNECT 到代理,所有经由浏览器发出的请求都会先经过fiddler的代理,然后由fiddler进行解密展示。也就是说,浏览器和fiddler、fiddler和服务器之间各自有一个HTTPS回话:

浏览器 <--HTTPS 会话1--> fiddler <--HTTPS 会话2--> 服务器

搭建反向代理

从上面描述的过程来看HTTPS少不了证书。证书是有对应的存储到硬盘的文件的,其文件有两种存储方式:文本和二进制。

文本存储用的是PEM格式(Privacy Enhanced Mail),这种格式用base64编码内容,可以用一般的文本编辑器打开查看,以 -----BEGIN XXX-----换行开头、以-----END XXX-----,XXX在这里是 CERTIFICATE。按理说PEM不止用来存储证书,也可以单独存储公钥和私钥。ssh-keygen命令默认生成的密钥对就是用的PEM格式。PEM格式可以用 *.crt*.pem作为拓展名。

二进制存储用的是ASN.1 格式,无法直接用文本编辑器打开,这种文件扩展名一般是 *.der。从 chrome 的小锁头导出来的证书就是用的这个格式。

证书

搭建反向代理之前首先得有一个有效的证书,为了说明效果、先不要用自己签的;我用的是腾讯云上面免费签1年的。一般来说需要三个文件:证书(.crt)、私钥(.key)和 root_bundle.crt(也不知道中文该叫啥)。

先把上面三个文件通过scp命令传输到ubuntu上的用户目录并且解压。

scp *.zip ubuntu@xxx.xxx.xxx.xxx:~/
# 输入密码就可以

自签证书

有时候基于开发测试原因、我们需要弄一个自己签发的证书,比如需要给IP签发证书,但是腾讯云的免费套餐并不支持~ 关于如何生成自签证书的详细步骤,可以参考台湾网友写的这篇文章,我这里简单记录一下操作步骤。

首先编写配置文件

[req]
prompt = no
default_md = sha256
default_bits = 2048
distinguished_name = dn
x509_extensions = v3_req

[dn]
C = CN
O = Test Inc.
OU = Test Department
CN = localhost

[v3_req]
subjectAltName = @alt_names

[alt_names]
DNS.1 = *.localhost
DNS.2 = localhost
IP.1 = 192.168.2.100 # 重要!如果要给IP签发证书就要改这个

dn小节里的内容都不重要,主要是alt_names里的东西要写对。然后通过openssl生成证书和私钥

openssl req -x509 -new -nodes -sha256 -utf8 -days 3650 -newkey rsa:2048 -keyout server.key -out server.crt -config ssl.conf

除非将该证书导入到OS、正常的浏览器都不会信任证书~所以如果要出现小锁头还得手动导入一下。

httpd 模拟后台

然后用一个httpd模拟无加密的后台服务。安装httpd,在ubuntu上其实是叫apache2。

sudo apt-get install apache2 -y
# 这个时候访问80端口会发现是HTTP

ubuntu httpd 的配置文件在 /etc/apache2/apache2.conf 。

单独使用HTTPS的httpd

在说明如何配置nginx之前,先来看下不使用nginx的https反代、怎么单独使用httpd的https功能。默认情况下SSL模块是没有开启的,可以 ls mods-enabled 看到下面没有ssl.conf 。实际上SSL模块的配置文件在 mods-available 下面。在ubuntu上对httpd启用/关闭功能需要通过 a2enmod/a2ensitea2dismod/a2dissite来执行。

sudo a2enmod ssl # 启用SSL模块。之后 ls mods-enabled 就可以看到新增的 ssl.conf
# 然后需要启用 https site
sudo a2ensite default-ssl
# 编辑 default-ssl.conf
vim sites-enabled/default-ssl.conf 

编辑default-ssl.conf的以下内容

#   SSL Engine Switch:
#   Enable/Disable SSL for this virtual host.
SSLEngine on # 确保这个要打开

#   A self-signed (snakeoil) certificate can be created by installing
#   the ssl-cert package. See
#   /usr/share/doc/apache2/README.Debian.gz for more info.
#   If both key and certificate are stored in the same file, only the
#   SSLCertificateFile directive is needed.
SSLCertificateFile      PATH_TO_YOUR_CERT # 证书文件的路径
SSLCertificateKeyFile   PATH_TO_YOUR_KEY # 证书文件私钥的路径

#   Server Certificate Chain:
#   Point SSLCertificateChainFile at a file containing the
#   concatenation of PEM encoded CA certificates which form the
#   certificate chain for the server certificate. Alternatively
#   the referenced file can be the same as SSLCertificateFile
#   when the CA certificates are directly appended to the server
#   certificate for convinience.
SSLCertificateChainFile PATH_TO_YOUR_CERT_CHAIN # bundle.crt的路径

保存之后重启下httpd,然后通过浏览器访问https就可以看到小锁头了。

sudo systemctl restart apache2

配置 nginx HTTPS

如果跟着前面启用了httpd的https功能,到这里为了说明效果得先把httpd的https关掉

sudo a2dissite default-ssl
sudo a2dismod ssl
sudo systemctl reload apache2

然后开始操作nginx

# 首先得装一个nginx
sudo apt-get install nginx

这里的nginx在安装完成之后并没有马上起来,估计是因为80端口被httpd占用掉了,先不管。nginx 默认的配置文件在 /etc/nginx/nginx.conf ,不过我打算用自己的配置文件/usr/share/nginx/nginx.conf 。这里参考了这篇文章

user www-data;
worker_processes auto;
pid /run/nginx.pid;

events {
	worker_connections 768;
}

http {
	sendfile on;
	tcp_nopush on;
	tcp_nodelay on;
	keepalive_timeout 65;
	types_hash_max_size 2048;
	# server_tokens off;

	# server_names_hash_bucket_size 64;
	# server_name_in_redirect off;

	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;

	gzip on;
    	server {
		listen 443 ssl;
		# 证书文件路径
		ssl_certificate PATH_TO_YOUR_CERT;
		# 私钥文件路径
		ssl_certificate_key PATH_TO_YOUR_KEY;
		ssl_session_timeout 5m;
                # SSL 版本
		ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
		ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
		ssl_prefer_server_ciphers on;
        	server_name YOUR_DOMAIN_NAME;
		
		location / {
			proxy_pass http://localhost;
			proxy_set_header Host $host;
			proxy_set_header X-Real-IP $remote_addr;
			proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
			proxy_set_header X-Forwarded-Proto https;
		}
    	}
}

有几点需要注意:

  • ssl_protocols 和 ssl_ciphers 要根据证书实际支持的版本以及密码suite来填
  • ssl_certificate nginx的证书跟httpd的还有点差异,实际上是由两个证书(root_bundle.crt, *.crt)组成的文件,同时必须让 root_bundle.crt 出现在前面,两个证书之间得有一个换行。
    • 腾讯云的SSL控制台有下载入口,可以根据不同的应用类型下载对应的证书;如果证书是从名为 nginx 的入口下载的可以直接用了,否则得手动拼接一下证书(注意腾讯云的证书后面没有换行😭)
    • 按照nginx的指引 cat www.example.com.crt bundle.crt > www.example.com.chained.crt

编辑保存配置文件之后先测试一下文件有没有语法错误

sudo nginx -t -c /usr/share/nginx/nginx.conf

确认无误之后可以启动nginx

# 启动
sudo nginx -c /usr/share/nginx/nginx.conf
# 重启
# sudo nginx -s reload -c /usr/share/nginx/nginx.conf
# sudo netstat -lntp 
# 可以看到nginx已经在监听443端口了

最后通过浏览器访问https,可以看到左上角出现一个小锁头了,真是可喜可贺,可喜可贺。

使用 nginx 容器

除了直接在服务器上部署nginx以外,还可以通过容器部署反向代理。具体的操作方法跟上面提到的流程差不多,需要在Dockerfile里把证书和nginx.conf拷贝到镜像里

FROM nginx
COPY nginx.conf /etc/nginx/nginx.conf
# 还有证书

如果被代理的服务也是容器化的,建议用docker-compose管理。这个方式有个好处就是可以利用docker自带的DNS,在nginx.conf的proxy_pass directive里可以直接用服务的名字作为域名,举个例子,假如被代理的是一个flask服务,名字是my-flask-service,在nginx.conf里可以直接写

proxy_pass http://my-flask-service

然后在docker-compose.yml里,nginx的部分需要depends_on my-flask-service。为了能让外部访问、记得在docker-compose.yml给nginx加端口映射。

如果被代理的服务是在宿主机上,有以下办法可以让nginx容器能访问宿主机的服务:

  • 让容器使用宿主机的网络。docker run的时候给一个参数 --net=host,nginx.conf 直接使用 localhost,也不需要加端口映射(亲测好用);
  • 对于mac/win的docker,或者是20.01以后版本的docker,可以使用域名http://host.docker.internal访问宿主机(没试过)。20.01版本以前的linux docker没有这个功能