要解决的问题

本地跑的服务没有公网地址。自己用浏览器访问没问题,但凡是需要外部主动回调的对接就全卡住:

  • 微信公众号的消息回调、网页授权回跳
  • 支付渠道的异步通知(notify_url)
  • 各种第三方 Webhook
  • OAuth 回调

这些都是对方的服务器要主动请求你,而且基本都强制 HTTPS。局域网 IP 和 localhost 在这里毫无意义。

常见的做法是用 ngrok 之类的隧道服务,但域名是随机的、每次重启就变,注册回调地址时很难受。自己有一台带公网 IP 的小机器的话,用 frp 自建更省心:域名固定,想开几个开几个。

整体形状

本地服务(:端口A) --frpc--> 公网机 127.0.0.1:端口B --nginx--> https://项目名.example.cn

三层各管一件事:

  • frpc/frps 只负责把 TCP 流量从内网穿到公网机的回环地址上。注意是 127.0.0.1,不对外暴露。
  • nginx 负责按域名分流、终结 TLS、加转发头。
  • DNS 用一条泛解析 *.example.cn 指向公网机,之后加子域名不用再动 DNS。

把 TLS 放在 nginx 而不是 frp 上,好处是证书只管一处,隧道那层保持简单。

frps 侧

公网机上跑 frps,配置可以极简:

bindPort = 7000

[auth]
method = "token"
token = "<一串够长的随机字符串>"

frpc 侧

内网机上,每个要暴露的端口一段 proxy:

serverAddr = "<公网机 IP>"
serverPort = 7000

[auth]
method = "token"
token = "<与 frps 相同>"

[[proxies]]
name = "myapp"
type = "tcp"
localIP = "127.0.0.1"
localPort = 3000       # 本地服务端口
remotePort = 3100      # 公网机上的端口

remotePort 要先查再定。撞上已被占用的端口,frpc 会直接连不上,而错误信息不一定明显:

ssh 公网机 'ss -ltn'

nginx 侧

server {
    listen 80;
    server_name myapp.example.cn;
    return 301 https://$host$request_uri;
}
server {
    listen 443 ssl;
    http2 on;
    server_name myapp.example.cn;

    ssl_certificate     /path/to/cert/fullchain.pem;     # *.example.cn 通配证书
    ssl_certificate_key /path/to/cert/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3100;
        proxy_http_version 1.1;
        proxy_set_header Host              $http_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 $scheme;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;
    }
}

几个别省的地方:

  • proxy_pass 写完整的 http://127.0.0.1:端口 只写端口号 nginx 会当主机名去做 DNS 解析,请求会挂住到超时而不是干脆报 502,极难定位。

  • X-Real-IPX-Forwarded-For 都要给。 后端做限流、风控、日志都靠它们。注意 $proxy_add_x_forwarded_for 是「在客户端送来的值后面追加真实 peer」,所以 XFF 首段是客户端可伪造的,末段才是本机 nginx 写的;X-Real-IP 被直接置为 $remote_addr,客户端追加不进去,最可信。

  • $connection_upgrade 需要在 http 块里有对应的 map,否则 websocket 会断:

    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }
    

把它变成一条命令

上面这套配置每加一个站点就要重写一遍,纯粹是体力活,而且容易手滑。写个脚本收敛掉:

site add <子域名> <端口>    # 生成 vhost、套通配证书、nginx -t、reload
site list                   # 所有站点及后端死活
site ports                  # 挑端口前先看占用
site rm <子域名>            # 删站

脚本里有三件事值得做:

  1. 写完先 nginx -t,不过就自动回滚。 新站删掉、旧配置还原,绝不留下让 nginx 起不来的配置。这一条最重要——一个坏配置能让整台机器上所有站点一起挂。
  2. 不覆盖已存在的站点,除非显式 --force。共享域名的机器上,手滑覆盖掉别人的站是很现实的风险。
  3. 校验子域名和端口格式,别让奇怪的输入拼进配置文件。

多项目共用一个域名时的规矩

一台穿透机、一个域名被多个项目共用时,冲突几乎是必然的。两条约定能省掉大部分麻烦:

  • 每个项目用自己的 <项目名>.example.cn,谁都不许占用顶级域。 顶级域被抢了,整个测试站的入口就废了。
  • remotePort 先查再用,并且在文档里维护一份已占用清单。

一点安全上的提醒

这套东西的本质是把内网服务开到公网上。所以:

  • 隧道只穿到公网机的 127.0.0.1,不要直接 0.0.0.0 暴露端口,让 nginx 做唯一入口。
  • frps 的 token 当密码对待,别进仓库、别进聊天记录。
  • 测试环境如果接的是真实的支付渠道或真实的公众号,它就不再只是「测试环境」了。这种情况下公网可达意味着真实风险,配置该禁用的禁用。