很多人一上来就满世界找备用 IP。先跑下面这段,判断该不该折腾:
for h in github.com api.github.com codeload.github.com github.githubassets.com; do
printf "%-34s " "$h"
if timeout 8 openssl s_client -connect "$h:443" -servername "$h" </dev/null 2>/dev/null \
| openssl x509 -noout -subject >/dev/null 2>&1; then
echo "TLS 握手成功"
else
echo "TLS 握手失败"
fi
done
这里刻意没用"能不能连上"当判据,而是要求真实 TLS 握手成功且证书解析得出来。原因是:链路被干扰时你会收到一个伪造的 TLS Alert——用"有没有收到响应"当判据会被骗到,探针报全部可用、真实流量全部失败。
结果通常只有两种:
| 现象 | 结论 |
|---|---|
| 四个域名全部握手失败 | 整体出口不可达,等链路恢复 |
| 主站握手失败,`api.` / `codeload.` / `githubassets.` 成功 | **按域名(SNI)定向阻断**,往下看 |
第二种是绝大多数人的情况。它的特点是 clone 可能正常、包管理器可能正常,只有网页登录进不去。很多人在这一步判断错了,把"一个域名被单独处理"当成"整个站点被封",方向错在这里,后面全是白费。
二、为什么换 IP、改 DNS 都没用
阻断掐的位置在 TLS 握手的第一个包。客户端发 ClientHello 时会带上自己要访问的域名,也就是 SNI 字段,是明文。中间的设备读的就是这个字段,看到匹配的就丢连接。
由此可以推掉三条常见思路:
换 IP 没用。 阻断不认 IP。那批流传的地址我逐个测过,全军覆没倒在其次,更麻烦的是同一个 IP 上一分钟返回 400、下一分钟超时,属于间歇性阻断——"找一个永不失效的 IP"这件事本身没有终点。
改 DNS 没用。 阻断发生在握手阶段,不在解析阶段。换公共 DNS 治不了根。
IPv6 也不是出路。 查到的 AAAA 记录是 CDN 的泛播节点,连上去会回一句 500 Domain Not Found,那不是源站。
还有一个容易被忽略的观察:这类阻断是触发式的。连续高频探测会把目标打成黑洞,静默两三分钟后直连又能恢复一部分。所以自建探针必须低频,粘性优先——只有当前路径真失败了才小规模重试。
三、完整配置
前提是手上有一台能正常访问目标站的服务器(这个前提有多脆,见第八节)。
思路:在客户端和源站之间加一段只转发、不解密的通道。
客户端 ──TLS(SNI=目标域名)──▶ 你的服务器:443 ──原样透传──▶ 源站:443
│
└── 其他域名 ──▶ 你自己的站点 127.0.0.1:8443
nginx 的 ssl_preread 只读 ClientHello 里的 SNI 用来选路,不终止 TLS。带来的直接好处是证书不变:客户端拿到的始终是源站官方签发的那张,浏览器零警告,因为整条通道没有解密流量。
/etc/nginx/stream.d/github-sni.conf,整个文件必须包在 stream { } 里:
stream {
map $ssl_preread_server_name $gh_target {
default 127.0.0.1:8443; # 其他域名 → 自己的站点
~^([a-z0-9_-]+\.)?github\.com$ 127.0.0.1:9443; # 目标域名 → 上游池
}
upstream gh_pool {
server 140.82.113.3:443 max_fails=1 fail_timeout=20s;
server 20.27.177.113:443 max_fails=1 fail_timeout=20s;
server 20.205.243.166:443 max_fails=1 fail_timeout=20s;
}
server {
listen 127.0.0.1:9443;
proxy_pass gh_pool;
proxy_next_upstream on;
proxy_connect_timeout 5s;
proxy_timeout 600s;
}
server {
listen 443;
listen [::]:443;
ssl_preread on;
proxy_pass $gh_target;
resolver 223.5.5.5 119.29.29.29 valid=300s ipv6=off;
proxy_connect_timeout 10s;
proxy_timeout 600s;
}
}
再在 nginx.conf 顶层(模块 include 之后)加一行:
include /etc/nginx/stream.d/*.conf;
四、多上游故障转移:这篇里最该抄走的一段
早期版本我只写死了一个上游 IP,结果很尴尬:那个 IP 一挂,就得等巡检脚本(五分钟一轮)来救,中间用户看到的就是 502。
改成上游池之后:
proxy_next_upstream on; # 一个上游挂了自动换下一个
server 140.82.113.3:443 max_fails=1 fail_timeout=20s;
关键在于 max_fails=1 配 fail_timeout=20s:某个上游连续失败一次就被标记,20 秒内不再分发请求给它,过期后自动放回来重试。整个过程由 nginx 自己完成,不再依赖外部脚本救火。
翻中文圈的加速教程,我没见谁写过这一层。但要说清楚——这不是发明,只是把 nginx 标准的故障转移参数用对了地方。
五、三个坑
一、map 必须整段包在 stream { } 里。 主配置是在 main 上下文里 include 这个文件的,少一层花括号直接报 map directive is not allowed here。
二、443 得让出来。 一个端口只能有一个监听者,而 443 通常被生产站点占着。把 http 层的虚拟主机挪到 127.0.0.1:8443,对外的访问体验完全不变。
三、用了变量 proxy_pass $var,就必须配 resolver。 否则启动直接报 no resolver defined to resolve。变量形式的转发把解析推迟到运行时,nginx 需要一个明确的解析器。
改完配置先做语法校验,别直接 reload。我们的部署脚本里是固定四步:写前备份 → 改配置 → nginx -t → 失败自动还原并重载。这四步一步都不能省,曾经因为盘上放着一份语法错误的配置被巡检撞上、触发一次回滚,白查了半小时。
六、怎么验证真通了
第一步,状态码与耗时。
SCHEME=https
HOST=github.com
for P in "" login; do
printf "%-10s " "/$P"
curl -s -o /dev/null -w "http=%{http_code} t=%{time_total}\n" --max-time 12 "$SCHEME://$HOST/$P"
done
主站与登录页都期望 HTTP 200,耗时在 1 秒以内。若走的是自有中转,%{remote_ip} 应该显示你的服务器 IP。
第二步,证书必须还是源站官方那张。 这是整套方案里我最看重的一条验收,它同时回答了"能不能通"和"安不安全":
openssl s_client -connect <服务器IP>:443 -servername github.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer
实测输出:
subject=CN=github.com
issuer=C=GB, O=Sectigo Limited, CN=Sectigo Public Server Authentication CA DV E36
如果签发者不是源站官方那张证书,说明有人在中间解密了,方案本身就错了——透传的价值恰恰在于不解密。
第三步,POST 通道。 登录提交走的是 POST,只测 GET 不够:
curl -s -o /dev/null -w "POST %{http_code}\n" --max-time 12 \
-X POST -d "login=x&password=x" "$SCHEME://$HOST/session"
返回 422 是正常的,那是源站的 CSRF 校验拒绝,恰好证明请求真的到达了源站而不是被中途丢弃。
七、上线前跑个配置体检
上面那三个坑,都能机器化检查。下面这个脚本把"修正前 / 修正后"两份配置各过一遍:
# -*- coding: utf-8 -*-
"""nginx stream 配置的故障转移体检。"""
import re
BAD = """
stream {
map $ssl_preread_server_name $gh_target {
default 127.0.0.1:8443;
}
upstream gh_pool {
server 140.82.113.3:443;
}
server {
listen 443;
proxy_pass $gh_target;
}
}
"""
GOOD = BAD.replace(
"server 140.82.113.3:443;",
"server 140.82.113.3:443 max_fails=1 fail_timeout=20s;").replace(
" proxy_pass $gh_target;",
" ssl_preread on;\n"
" proxy_pass $gh_target;\n"
" resolver 223.5.5.5 119.29.29.29 valid=300s ipv6=off;").replace(
" server {\n listen 443;",
" server {\n listen 443;\n proxy_next_upstream on;")
def audit(conf):
up = re.findall(r"^\s*server\s+\S+:443[^;]*;", conf, re.M)
issues = []
for line in up:
if "max_fails" not in line or "fail_timeout" not in line:
issues.append("上游缺故障转移参数: " + line.strip())
if "proxy_next_upstream" not in conf:
issues.append("未开启 proxy_next_upstream(上游挂了不会自动切换)")
if re.search(r"proxy_pass\s+\$", conf) and "resolver" not in conf:
issues.append("用了变量 proxy_pass 却没有 resolver(nginx 起不来)")
if "ssl_preread on" not in conf:
issues.append("未开启 ssl_preread(无法按 SNI 选路)")
return len(up), issues
for name, conf in (("修正前", BAD), ("修正后", GOOD)):
n, issues = audit(conf)
print("[%s] 上游 %d 个,问题 %d 项" % (name, n, len(issues)))
for it in issues:
print(" - " + it)
print("结论: 修正后零问题,可上线")
实跑输出:
[修正前] 上游 1 个,问题 4 项
- 上游缺故障转移参数: server 140.82.113.3:443;
- 未开启 proxy_next_upstream(上游挂了不会自动切换)
- 用了变量 proxy_pass 却没有 resolver(nginx 起不来)
- 未开启 ssl_preread(无法按 SNI 选路)
[修正后] 上游 1 个,问题 0 项
结论: 修正后零问题,可上线
把 BAD 换成你线上那份配置,就能在 reload 之前知道自己漏了哪一项。nginx 的错误日志里如果出现 map directive is not allowed here 或 no resolver defined,基本就是这三条里的前两条。
八、说清天花板:这套方案会失效
必须把这一段写出来,否则前面的实测数字会误导人。
这套方案的隐含前提是"你的服务器能正常访问目标站"。而这个前提并不永久成立。我这台服务器在部署时(9 月)主站 http=200 t=0.8s 干干净净;到了 10 月再测,同一个出口已经变成:
主站 http=000 t=10.00s ← 被按 SNI 定向阻断了
api 子域 http=200 t=0.32s ← 仍然正常
也就是说,阻断会向机房出口蔓延,而且换 IP 救不了(它本来就不是 IP 层的事)。上游池能解决"某个 IP 挂了",解决不了"整条出口被按域名盯上"。
这也是我不建议把它当唯一出路的原因:它是一条自建通道,不是免疫通道。真要长期稳定,得多路径共存——直连、自建中转、社区维护的工具同时留着,哪条活着走哪条。
九、边界
- 这套方案只能救被你指定的那些域名。换别的目标,服务器出口同样可能不通。
- 需要 443 端口空闲,或者你能把占用它的服务挪走。
- 未备案域名的服务器不能直接对外开 443 做站点,但做纯 stream 转发不涉及这块;实际部署请遵守所在地和云服务商的规定。
- 服务器证书过期、上游池全挂、出口被断,任何一种都会让通道失效——所以监控和低频探针不是可选项。
我们是谁:一个人加 AI 的小团队,主业是企业知识库与 AI 客服落地。这类网络环境的坑是干活路上顺手踩的,踩完就整理出来。
说明:本文的配置、命令与实测数据均为实机验证;文字由作者与 AI 助手协作整理,观点与结论由作者负责。
适合被 SNI 定向阻断、只想恢复网页登录又不想暴露证书的自建场景,配置可直接抄;但别把它当免疫通道,出口被盯上时依然会失效,多路径共存更稳。