TunnelMesh Nginx 推荐配置
TunnelMesh Nginx 推荐配置
本文适用于 Nginx 作为公网 HTTPS/WSS 入口,TunnelMesh Server 运行在本机 127.0.0.1:8080。公网只开放 80/443;Agent、Client 和 SSH over WebSocket 都通过 HTTPS/WSS 复用入口。
管理后台前端有两种部署方式:默认由 Server 内嵌 internal/server/web_dist 提供,
或由 Nginx 独立托管 web/dist。独立静态文件的构建、发布和 try_files 配置见
管理后台前端构建与部署;下面的配置适用于 Server 内嵌模式,
也可作为独立静态模式的 API/WSS 反代部分。
推荐配置
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 速率与并发限制分三层,缺一层都会留下可被单点耗尽的路径:
# 1) /api/ 请求速率(认证与数据库开销最大);
# 2) WebSocket 握手速率(每次握手都要过一次 token 校验并注册会话,
# 只有 limit_conn 挡不住"高频建连即断连"的抖动型消耗);
# 3) 每来源与全局并发连接数(挡住慢速占位)。
# zone 大小按状态条目数估算:10m 约 16 万个 IP 条目,正常出口 NAT 场景足够。
limit_req_zone $binary_remote_addr zone=tunnelmesh_api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=tunnelmesh_ws_handshake:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=tunnelmesh_page:10m rate=50r/s;
limit_req_zone $binary_remote_addr zone=tunnelmesh_health:10m rate=10r/s;
# tunnelmesh_ws 只统计 WebSocket 长连接,tunnelmesh_conn 统计普通 HTTP 并发,
# 两个 zone 分开,才能分别调参而不互相牵连(例如放大后台并发却收紧 SSH 会话)。
limit_conn_zone $binary_remote_addr zone=tunnelmesh_ws:10m;
limit_conn_zone $binary_remote_addr zone=tunnelmesh_conn:10m;
# 全局并发上限用 $server_name 作 key,即整个 server 块一个计数器:
# 它保护的是上游连接池与 Server 进程,而不是某个来源。
limit_conn_zone $server_name zone=tunnelmesh_ws_total:1m;
# 429 而不是默认 503:503 会被 LB 与 nginx 自身当成"上游不健康"从而重试/摘除节点,
# 而限流是容量策略,不该触发摘除。
limit_req_status 429;
limit_conn_status 429;
upstream tunnelmesh_server {
server 127.0.0.1:8080;
keepalive 32;
# 必须不大于 server.http.idle_timeout(默认 120s),否则 Nginx 会复用一条
# Server 已经关闭的 keep-alive 连接,表现为间歇性 502。
keepalive_timeout 60s;
}
server {
listen 80;
server_name tunnel.example.com
"~^tm-[a-z0-9](?:[a-z0-9-]{0,58}[a-z0-9])?\.tunnel\.example\.com$"
"~^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?-(?:[0-9]{1,3}-){4}[0-9]{1,5}\.apps\.example\.com$";
return 308 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name tunnel.example.com
"~^tm-[a-z0-9](?:[a-z0-9-]{0,58}[a-z0-9])?\.tunnel\.example\.com$"
"~^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?-(?:[0-9]{1,3}-){4}[0-9]{1,5}\.apps\.example\.com$";
ssl_certificate /etc/letsencrypt/live/tunnel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tunnel.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:TLS:10m;
client_max_body_size 2m;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_connect_timeout 5s;
# API must precede SPA fallback.
location ^~ /api/ {
limit_req zone=tunnelmesh_api burst=40 nodelay;
limit_conn tunnelmesh_conn 32;
proxy_pass http://tunnelmesh_server;
proxy_http_version 1.1;
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;
proxy_set_header Authorization $http_authorization;
}
# Agent and Client authenticated WebSockets.
location = /ws/agent {
limit_req zone=tunnelmesh_ws_handshake burst=10 nodelay;
limit_conn tunnelmesh_ws 100;
limit_conn tunnelmesh_ws_total 2000;
proxy_pass http://tunnelmesh_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header Origin $http_origin;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
location = /ws/client {
limit_req zone=tunnelmesh_ws_handshake burst=10 nodelay;
limit_conn tunnelmesh_ws 100;
limit_conn tunnelmesh_ws_total 2000;
proxy_pass http://tunnelmesh_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header Origin $http_origin;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
# Browser WebSSH/SFTP one-time ticket bridge. The ticket is in the query
# string for this request only; do not log query strings in access logs.
location ^~ /ws/webssh/ {
limit_req zone=tunnelmesh_ws_handshake burst=5 nodelay;
limit_conn tunnelmesh_ws 20;
limit_conn tunnelmesh_ws_total 2000;
proxy_pass http://tunnelmesh_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header Origin $http_origin;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# SSH/websocat binary bridge. Keep exact path if it is enabled.
location = /ws/tcp {
limit_req zone=tunnelmesh_ws_handshake burst=10 nodelay;
limit_conn tunnelmesh_ws 100;
limit_conn tunnelmesh_ws_total 2000;
proxy_pass http://tunnelmesh_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header Origin $http_origin;
proxy_set_header Authorization $http_authorization;
proxy_buffering off;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
# 健康检查必须能被外部监控与 LB 访问,因此只给一个宽松的速率兜底:
# 10r/s 足够任何探测频率,同时挡住把健康检查当放大器的脚本。
location = /health/live {
limit_req zone=tunnelmesh_health burst=20 nodelay;
proxy_pass http://tunnelmesh_server;
proxy_set_header Host $host;
}
location = /health/ready {
limit_req zone=tunnelmesh_health burst=20 nodelay;
proxy_pass http://tunnelmesh_server;
proxy_set_header Host $host;
}
# Prefer binding /metrics to an internal management address. If it must
# pass through Nginx, protect it with network ACL or mTLS/basic auth.
location = /metrics {
# Prometheus 抓取会被 429 打断,因此兜底限流的速率要覆盖抓取间隔;
# 更推荐直接放行内网管理地址(见"关键约束")。
limit_req zone=tunnelmesh_health burst=20 nodelay;
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
proxy_pass http://tunnelmesh_server;
proxy_set_header Host $host;
}
# Catch-all reverse proxy, and the one block that must never be deleted.
# The Server serves the SPA history fallback, but every frontend path still
# has to reach it: without this block `/` falls through to the Nginx or
# OpenResty default site and renders its welcome page. It is the shortest
# prefix, so the /api/ and /ws/* locations above still win.
location / {
# 管理后台首屏、静态资源与 SPA history fallback 都在这个兜底 location 里,
# 且全部免认证:没有限流时它是唯一可被无凭据打满的路径。
# 50r/s + burst 200 覆盖一次首屏并发拉取,又不足以支撑扫描器。
limit_req zone=tunnelmesh_page burst=200 nodelay;
limit_conn tunnelmesh_conn 64;
proxy_pass http://tunnelmesh_server;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
}
tp-* HTTP 代理入口(OpenResty)
托管路由的 http-proxy 类型提供的是正向代理入口(https://tp-<name>.<domain_suffix>),不是
反向代理。它与本文上面的 server 块共用 443,由 SNI 分流:既有块匹配 admin 与 tm-* 域名,
tp-* 块用正则 server_name 匹配 tp-<name>.<domain_suffix>,两块互不影响。
本文不重复模板内容。模板、占位符、渲染示例、容量评估、reload 影响与回滚步骤见
OpenResty tp-* 代理入口,产物在
deploy/openresty/。
两点前提必须注意:
- 必须是 OpenResty,纯 nginx 做不到:CONNECT 跳过 location 匹配,只有打过
ngx_http_proxy_connect_module补丁的内核才会注册$connect_host/$connect_port并允许 server 级access_by_lua_file接管;lua-nginx-module的ngx.req.socket(true)才能做双向 splice。上线前用nginx -V加deploy/openresty/spike-connect-check.sh做两级检查。 - tp-* server 块必须省略
http2:ngx.req.socket(true)在 HTTP/2 下游不可用,proxy_connect 模块的 Known Issues 也明确不支持 HTTP/2 的 CONNECT。既有 admin 块用的是 per-server 的http2 on;,两块各自独立,因此新块省略即可,不需要改动既有块(要求内核 nginx >= 1.25.1)。
限流与容量取值
限流值不是玄学,下面的数字与本文其余配置以及 Server 自身的约束是一对一的:
| 位置 | 指令 | 依据 |
|---|---|---|
/api/ |
limit_req rate=20r/s burst=40 |
每个请求都要过一次 token 校验与数据库访问;40 的突发覆盖控制台一次刷新拉取的多个接口。 |
/api/ |
limit_conn tunnelmesh_conn 32 |
单来源并发慢请求的上限,避免一个浏览器扩展占满上游 keepalive 池。 |
/ws/agent、/ws/client |
limit_req zone=tunnelmesh_ws_handshake rate=5r/s |
Agent 池重启、网络抖动后的集中重连应当放行;持续高于 5 次/秒的握手只可能是脚本。 |
/ws/agent、/ws/client |
limit_conn tunnelmesh_ws 100 |
单来源可能承载多个 NAT 后的 Agent;server.agents.max_connections_per_agent(默认 64)是每个 Agent 身份的上限,两者维度不同,不能相互替代。 |
/ws/webssh/ |
limit_conn tunnelmesh_ws 20、握手 burst=5 |
会话可长达 server.webssh.session_ttl,占位成本最高;单来源并发另受 server.webssh.max_active_sessions_per_user 约束。 |
location / |
limit_req rate=50r/s burst=200 |
免认证的静态资源与 SPA fallback,是扫描器默认命中的路径。 |
| 全局 | limit_conn tunnelmesh_ws_total 2000 |
保护上游与 Server 进程;必须小于 worker_processes * worker_connections / 2,且不小于 limit_conn tunnelmesh_ws 乘以预期活跃来源数。 |
三处必须同时看的约束:
proxy_read_timeout与server.http.idle_timeout:前者 180s 覆盖 30s 心跳的三个周期,后者是 Server 侧 keep-alive 空闲上限(默认 120s)。要让 Nginx 主动回收空闲上游连接,就把keepalive_timeout tunnelmesh_server(upstream 内)调到不大于server.http.idle_timeout,否则 Nginx 会复用一条 Server 已经关闭的连接并回 502。server.http.max_connections(默认 0 表示不限制):Nginx 在前时通常保持 0,容量由limit_conn决定;Server 直接暴露公网(无 Nginx、或location /直连)时必须设置一个与进程容量匹配的正数,否则只有ReadHeaderTimeout/MaxHeaderBytes这一层保护。超限的新连接会被立即关闭(表现为 502/连接重置),不会排队。- 429 的处理:
limit_req/limit_conn命中后返回 429,不写上游、不计入proxy_next_upstream。Agent 与 Client 的连接池按指数退避重试,因此偶发 429 只延长重连时间;控制台收到 429 会提示重新登录,若持续出现应当先核对上表而不是先放大burst。
关键约束
proxy_http_version 1.1、Upgrade、Connection、proxy_buffering off是 WebSocket 必需项。- 30 秒心跳下,
proxy_read_timeout建议至少 180 秒,避免短暂网络抖动被 Nginx 提前断开。 - 必须透传
Authorization和Origin;不要把 token 放到 URL query 或 Cookie。 /api/、/ws/agent、/ws/client、/ws/webssh/、/ws/tcp使用精确或^~location,优先于 SPA fallback。- 每个
location都必须同时有速率(limit_req)和并发(limit_conn)两类限制:只有限速挡不住慢速长连接占位,只有限并发挡不住高频握手;location /兜底也不例外。 limit_conn不会跨 location 累加,同一 location 内多条limit_conn才会同时生效;因此 WebSocket location 里tunnelmesh_ws(单来源)与tunnelmesh_ws_total(全局)要一起写,不能指望从 server 块继承。- 管理后台的前端路由不需要在 Nginx 配置
try_files或 rewrite:SPA history fallback 由 Server 内嵌静态服务完成,location /只需proxy_pass到 Server。本文件中的/api/与/ws/*location 只服务于反代优先级和 WebSocket 升级,与前端路由无关。仅当改用“Nginx 独立静态文件模式”直接托管web/dist时,才需要try_files $uri $uri/ /index.html;与application/wasm类型补充,见 前端构建与发布。 - 但
location /兜底反代本身不能删:它不是前端路由配置,而是/与所有前端路径到达 Server 的唯一入口。删掉后请求落到 Nginx/OpenResty 默认 root 的index.html,表现为 “Welcome to OpenResty!” 欢迎页,而/api/接口可能依旧正常——这是该误删最典型的识别特征。 - WebSSH/SFTP 会话可能持续数小时;
/ws/webssh/的读写超时建议不低于server.webssh.session_ttl,并保持双向proxy_buffering off。若缩短超时,需同步评估 SSH keepalive 和server.webssh.idle_timeout。 - 静态资源默认由 Server 透传,Server 已固定
.wasm的Content-Type: application/wasm。若改为 Nginx 直接托管web/dist,必须在types中补充application/wasm wasm;,否则浏览器会拒绝 WASM 流式编译并回退到更慢的 ArrayBuffer 实例化。 /metrics不建议暴露公网;优先让 Prometheus 访问 Server 内网管理地址。- 显式泛域名只允许单层
tm-<name>.tunnel.example.com;动态域名使用<agent>-<a>-<b>-<c>-<d>-<port>.<server.dynamic_suffix>。Nginx 正则中的后缀必须与server.dynamic_suffix、wildcard DNS 和证书一致;正则只做域名形状筛选,IP 八位组范围、端口范围、危险地址和 Agent 策略由 Server 再次校验。 - 泛域名只解决 HTTP/HTTPS/WSS 路由;VPN 网关的公网 UDP 端口不经 Nginx,需在云安全组与主机防火墙上单独放行(见 ADR 0002,仅
-tags vpn构建提供)。 - tp-* server 块禁止
http2:ngx.req.socket(true)在 HTTP/2 下游不可用,proxy_connect 模块的 Known Issues 也明确不支持 HTTP/2 的 CONNECT。 - tp-* 的
location /必须显式写proxy_set_header Proxy-Authorization $http_proxy_authorization;:它是 hop-by-hop 头,Nginx 默认不转发给上游,漏掉这一行会让所有非 CONNECT 的代理请求返回 407。 - 不能用
proxy_connect;+proxy_pass做链式转发:模块 README 明确 “Anylocation {}block,upstream {}block and any other standard backend/upstream directives, such asproxy_pass, do not impact the functionality of this module.”,模块会自己直连目标,路由身份与Proxy-Authorization全部丢失。本项目的做法是不启用proxy_connect;指令,只用补丁提供的$connect_host/$connect_port变量与 server 级access_by_lua_file,把 CONNECT 原样搬到 Server 的内部入口(server.proxy_entry.listen),策略全部由 Server 执行。 - 修改配置后先执行
nginx -t,再 reload;证书轮换必须验证 WSS 和 API 登录。
验证命令
nginx -t
curl -fsS https://tunnel.example.com/health/live
curl -fsS https://tunnel.example.com/health/ready
curl -fsS https://tunnel.example.com/metrics
websocat --binary -H='Authorization: Bearer <client-service-token>' \
wss://tunnel.example.com/ws/client
# 限流确实生效(超过 burst 后应出现 429,而不是 503):
for i in $(seq 1 60); do
curl -s -o /dev/null -w '%{http_code}\n' https://tunnel.example.com/health/live
done | sort | uniq -c
# 命中限流时 nginx 会记 limit_req/limit_conn 日志,用于区分"Nginx 挡掉"与"Server 拒绝":
tail -n 50 /var/log/nginx/error.log | grep -E 'limit_req|limit_conn' || echo "no limit hits yet"
不要把真实 token、证书私钥或生产域名凭据写入配置仓库。