这个博客是今天下午搭起来的,过程里有个绕不开的问题:443 端口早就被占了。

起因

我这台新加坡的小机器身兼数职:SoftEther VPN 中心服务器、几台设备的组网中枢、顺带跑点代理。其中 443 是 SoftEther 在监听——这不是随便挑的端口,而是给那些卡在 CGNAT 后面、UDP 不通的节点留的兜底通道:L2TP/IPsec 起不来时,自动降级到走 TCP 443 的 SSL 隧道。443 长得像正常 HTTPS,几乎不会被中间设备拦。

现在我想在同一个 IP 上开博客。博客要 HTTPS,HTTPS 要 443。而一个端口只能被一个进程 bind。

几条走不通的路

换个端口给博客,比如 8443。技术上没问题,但 https://linlook.live:8443 这种链接发出去就是个笑话,谁也不会记。

把 VPN 挪走,让出 443。等于把兜底通道拆了——真到了需要它的网络环境下,那些节点就彻底连不上了。本末倒置。

让 SoftEther 自己反代。它确实有点 HTTP 相关的功能,但那是为管理界面准备的,不是给你托管站点用的。

真正的解法:按 SNI 分流

关键在于 TLS 握手的第一个包。

客户端发出的 ClientHello 里有个 SNI 字段,写着"我要访问哪个域名"——而这部分是明文的,加密还没开始。也就是说,一个中间人在完全不解密、不持有任何证书的情况下,光读这个字段就能知道该把连接转给谁。

nginx 的 stream 模块带了 ssl_preread,干的就是这件事:偷看一眼 ClientHello,把 SNI 提取成变量,然后按它做四层转发。

于是架构变成这样:

公网 :443 ──► nginx stream (ssl_preread)
              ├─ SNI = linlook.live ──► 本机 HTTPS 博客
              └─ 其它 / 没有 SNI    ──► SoftEther

配置本身很短:

map $ssl_preread_server_name $backend443 {
    linlook.live  blog;
    default       softether;
}

upstream blog      { server 127.0.0.1:4443; }
upstream softether { server 127.0.0.1:4445; }

server {
    listen 443 reuseport;
    ssl_preread    on;
    proxy_protocol on;
    proxy_pass     $backend443;
}

这里有两个让它成立的巧合,值得说清楚:

一、VPN 客户端天生不带 SNI。 它们连的是 47.84.69.171 这个 IP,而 SNI 里放 IP 地址是不合规的,客户端根本不会发。所以所有 VPN 流量都自动落进 default 分支,一行特判都不用写。

二、非 TLS 流量也能正确处理。 OpenVPN over TCP 有自己的帧格式,第一个字节不是 TLS 握手的 0x16ssl_preread 发现这不是 ClientHello 就直接放行、不设变量,同样走 default。我特意构造了一段非 TLS 字节流打过去验证,分流日志显示它准确落到了 SoftEther 那一侧。

改完之后,SoftEther 的监听器从 443 挪到本机的 44443,nginx 接管 443。VPN 客户端一个字都不用改,它们连的还是 IP:443,只是中间多了一跳转发。

两个坑

真实 IP 丢在了第一跳

stream 转发是四层的,后端看到的源地址会变成 127.0.0.1。解决办法是 PROXY protocol——nginx 在转发时额外写一小段头部,告诉下游"真正的客户端是谁"。

我第一版把 proxy_protocol on 加在了内部的中转 server 上。配置能跑,博客也正常,但访问日志里清一色全是 127.0.0.1。

原因很简单:那一跳的源地址本来就已经是 127.0.0.1 了。真实 IP 在更前面的 443 那一层就丢了,你在下游怎么补都补不回来。PROXY protocol 必须由第一个知道真实 IP 的人写下来。

改成在 443 的 server 上统一开启,再让 VPN 分支多走一个 listen ... proxy_protocol 的中转 server 把这个头剥掉(SoftEther 不认识它),问题就解决了。

顺带一提,这个 bug 比看上去更讨厌:我的写作后台有个"连续失败 6 次锁 15 分钟"的登录限流,它是按 IP 计数的。如果所有请求的来源都是 127.0.0.1,那就等于全世界共用一个计数器——任何人试错几次,所有人一起被锁在门外。

新 vhost 悄悄抢走了默认站点

我把博客的 server 块放进了 /etc/nginx/conf.d/。而 nginx 主配置里那个老的 server_name _ 默认站点,是在 include conf.d/*.conf 之后才定义的。

nginx 的规则是:某个端口上第一个出现的 server 块,就是这个端口的默认站点(除非有人显式声明 default_server)。于是我的新 vhost 顺位成了 80 端口的默认站点,所有按 IP 访问、Host 对不上的请求,全被它 301 重定向到了博客域名。

后果是那台机器上原本用 http://IP/shell/mesh.sh 分发的组网脚本挂了——严格说不算完全挂,curl -fsSL 会跟着跳转,最后还是能下到文件,但凡是不跟随重定向的调用方就直接拿到一个 301 空响应。这种"看起来还能用"的故障最烦人。

修法是一行:给老的默认站点显式加上 default_server

收尾

整套东西跑起来之后:

  • 博客是 Hugo 生成的纯静态页面,nginx 直接吐文件,没有数据库、没有 PHP、没有后台可以被人打
  • 证书用 acme.sh 自动签发和续期
  • VPN 的兜底通道原样保留,客户端零改动
  • 多出来的开销,大概就是每个连接多一次内核态转发

一个端口,两种完全不同的服务,靠的只是握手时那个没加密的域名字段。

顺便,这篇文章本身也是在这台机器上写完发出来的——写作后台就在博客的 /admin 下面,手机浏览器打开就能用。