nginx stream: 复用443端口 使https与sstp共存

微软的SSTP是个很有意思的协议, 和大多数SSL vpn一样, 都先经过ssl协商之后才进入后面的数据报文收发。它最大的好处无疑是每台Windows系统的机器都自带客户端。没错, 但似乎用的人不多, 暂时也没有任何封杀的消息, 是一个不错的alternative。它有着所有vpn/tun/tap的好处与坏处: 工作在网络层, 方便路由配置; 但面对想直接代理某个应用的场景又不如shadowsocks、v2等可以提供socks代理的应用层工具强。

SSTP默认工作在443端口, 因为其设计目的之一便是突破某些内部网络对非443/80等端口访问的封锁。这带来了一个问题: 如果一台服务器上同时要在443端口上提供https和SSTP等类似的SSL VPN服务应该怎么办?

我们知道nginx的stream模块可以做四层的转发, 也就是将TCP流直接转到另一个地址:端口, 相当于一条常见的iptables nat命令。因为设计目的不同, stream模块提供了比iptables层级更高的功能。这次我们要用到他的ssl_preread子模块, 通过对server_name字段的嗅探, 将流转发到上游的不同服务。

之所以这么做, 完全是因为https和SSTP在协商阶段都使用了SSL/TLS协议。可以查到在前人的经验中, 通过ssl_preread_protocol的SSL/TLS版本匹配将并不使用TLS/SSL的ssh匹配回落至default (参考C系语言的switch…case…default), 从而实现复用443端口提供ssh。我们也完全可以在nginx和SSTP服务器上分别指定TLS的版本号, 在stream里加以区分后转发。但这似乎不太符合奥卡姆剃刀的原则, 需要对原nginx site conf进行不小的修改, 也不便于往后对TLS协议版本的升级兼容。由于https和SSTP在SSL握手的时候都可以使用SNI指定请求的host, 那么这时候就可以用ssl_preread_server_name将host提出来, 然后根据域名进行转发, 而不需要对TLS版本做匹配。

这里有一个问题, 通常nginx绑定的是0.0.0.0或者[::], 也就是任意ipv4或ipv6地址上的80/443端口。但stream和http是同级别的模块, stream监听了任意地址的443端口后必然和其后http监听443端口的行为冲突。使用service nginx checkconf即可验证这个想法。一个思路是http绑定一个环回地址, 不一定是127.0.0.1, 可以是127.0.0.0/8中的任意一个, 或者根据域名的不同指定多个, 然后在stream的upstream语句块中指定这个地址:443即可。另一个问题是IPv6环回地址的问题, 默认只有[::1]一个, 不像IPv4是一整个/8地址段。这里可以使用IPv6的Site-local地址段, 用加路由的方式将这段地址的路由指向lo环回界面。但虽然这样可以Ping通地址段内的任何地址, 但界面上并没有实际分配到这个地址, 因此tcp bind是会报错的。那么解决办法就只有给lo加上一个或多个site-local地址这种方法了。我相信有更加优雅的的方法, 希望有人可以告诉我。

以下是解决方案:

# 给环回加site-local地址
$ sudo ip -6 addr add fd44:1443::3/64 dev lo
$ cat /etc/nginx/nginx.conf

...

stream {

        map $ssl_preread_server_name $name {
                vpnsstp.hostname    sstp;
                default             sslweb;
        }

        upstream sstp {
                server 127.0.0.1:10443;  # SSTP服务地址:端口
        }

        upstream sslweb {
                server 127.0.0.3:443;      # https, ipv4
                server [fd44:1443::3]:443; # https, ipv6
        }

        server {
                listen          233.233.233.233:443;   # 本机公网IPv4地址
                listen          [2001:db8::2333]:443;  # 本机公网IPv6地址
                proxy_pass      $name;
                ssl_preread     on;
        }

}

...

$ cat /etc/nginx/sites-enabled/default

# 依然可以设置301跳转, 不影响使用

server {
        listen          80;
        listen          [::]:80;
        server_name     *.hostname;
        server_name     hostname;
        return 301      https://$host$request_uri;
}

server {
        listen   127.0.0.3:443 ssl;
        listen   [fd44:1443::3]:443 ssl;
        server_name hostname;

        ...

}