Skip to content

路线 B:反向代理(nginx 或 Caddy)

反向代理托管静态产物,把 /api 和实时通知的 /hub 转给后端。浏览器只看到一个源,所以同样不需要 CORS。nginx 和 Caddy 两份配置等价,选一份抄走就行。

该选哪个

已有 nginx 网关的,照抄 nginx 那份。新拉一台机器、想省掉 TLS 手工活的,选 Caddy。站点标签只要写真实域名,别写 :80,Caddy 就会自动申请并续期 Let's Encrypt 证书。仓库的容器化与多副本交付默认用 Caddy,也是这个原因。

nginx

nginx
server {
    listen 80;
    server_name admin.example.com;

    # 上传大小上限要 ≥ SmartAdmin:Upload:MaxSizeMb(默认 20MB);
    # nginx 默认只有 1m,不改的话上传大文件会得到 413 而不是内核的错误码。
    client_max_body_size 32m;

    root /var/www/smart;          # web/dist 的内容
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;   # 前端 history 路由回退
    }

    location /api/ {
        proxy_pass http://127.0.0.1:5000;   # 后端监听地址
        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 $scheme;
    }

    # SignalR 实时通知 Hub:WebSocket 升级必须显式转发 Upgrade/Connection,
    # 漏掉这段,实时通知会静默退回 30 秒轮询。
    location /hub/ {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        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 $scheme;
    }

    # 探针:编排层/负载均衡直接探这里
    # 漏掉这段,/health 会落进上面的 try_files 拿到一张 index.html,后端挂了探针照样返回 200。
    location /health {
        proxy_pass http://127.0.0.1:5000;
        access_log off;
    }
}

Caddy

用真实域名当站点标签,Caddy 会自动签发和续期证书。本地或没域名时才用 :80。这份是主机直装版,和容器化与多副本容器里那份 web/Caddyfile 是同一套思路。

admin.example.com {
    # 上传大小上限要 ≥ SmartAdmin:Upload:MaxSizeMb(默认 20MB)
    request_body {
        max_size 32MB
    }

    # 必须用 handle 块,别把 reverse_proxy 和 try_files 平铺在一起:
    # Caddy 按内置指令顺序执行(不按书写顺序),try_files 属 rewrite 阶段、早于 reverse_proxy。
    # 平铺时 /api/... 这种磁盘上找不到文件的路径会先被 try_files 改写成 /index.html,
    # 于是 API 请求拿到的是一张 HTML —— 前端整个连不上后端(实测:登录直接返回空)。
    handle /api/* {
        reverse_proxy 127.0.0.1:5000
    }

    # SignalR 实时通知 Hub:reverse_proxy 自动处理 WebSocket 升级
    handle /hub/* {
        reverse_proxy 127.0.0.1:5000
    }

    # 探针:编排层/负载均衡直接探这里
    handle /health* {
        reverse_proxy 127.0.0.1:5000
    }

    # 兜底:前端 history 路由,未命中静态文件就交给 SPA(深链刷新不 404)
    handle {
        root * /var/www/smart
        try_files {path} /index.html
        file_server
    }
}

两份配置都还差一步

代理这边写了 X-Forwarded-For(nginx),或者让 reverse_proxy 自动带上(Caddy),都还不够。内核那边也得采信它才行,做法见下面「反向代理之后:让内核取到真实客户端 IP」一节。不配会怎样?后端看到的永远是代理那一个 IP。于是全体用户共享同一个限流桶,一个人狂点登录,就能把所有人的登录都限死。按 IP 的爆破防护也归零,登录日志里的 IP 列全成了代理地址。

反向代理之后:让内核取到真实客户端 IP

任何反代(nginx / Caddy / Traefik / k8s ingress)之后都必须配这一段:

json
{
  "SmartAdmin": {
    "Api": {
      "ForwardedHeaders": {
        "Enabled": true,
        "KnownProxies": [ "10.0.0.8" ],
        "KnownNetworks": [ "172.16.0.0/12" ]
      }
    }
  }
}
  • 默认关。不在代理后面却打开它 = 允许任何人伪造自己的 IP。
  • 打开了就必须声明受信来源KnownProxies 给具体 IP,KnownNetworks 给 CIDR 网段。容器编排下代理 IP 不固定,用网段更实际。一个都不给,启动直接报错。这不是挑剔。无条件采信 X-Forwarded-For 比不解析更糟:攻击者每个请求伪造一个不同 IP,就能无限开新的限流分区,把限流完全绕过。他还能把爆破失败记到别人头上。
  • 受信的是来源地址,所以任何能直连后端端口的人都能伪造 IP。反代之后就别把后端端口暴露出去。compose 里那个调试端口因此只绑了 127.0.0.1

基于 Apache License 2.0 开源