Skip to content

Caddy 网关与生产路由拓扑

本章目标:

  1. 看懂 Caddyfile.keycloak-v1 的 :80 / :443 双 server 块如何分工:HTTPS canonical 入口 + IP 应急兜底
  2. 掌握 forward_auth 矩阵——哪些 /api/* 路由过鉴权、哪些(登录流)不过、各自的 strip 规则
  3. 理解 HTTPS canonical hostname 301 跳转 + 多 host 白名单 + IP 路径回滚兜底这三件事如何协同

TL;DR

生产唯一对外入口是 platform-gateway(Caddy 2),配置文件 deploy/prod/Caddyfile.keycloak-v1 有两个完全平行的 server 块::80(HTTP)和 acdm.r7.chinasoftinc.com(HTTPS,Let's Encrypt 通配证书)。:80 块里域名 host 被 301 跳到 HTTPS,IP host(172/101)保留原路由作应急回滚兜底。除了 /api/acdm/auth/*(登录流自身)和 /auth/login 速率限制例外,所有 /api/* 路由都先打 forward_auth acdm-backend:8002/auth/verify 校验 acdm_session cookie,通过才反代到对应后端并 strip 前缀。多 host 安全靠后端 ACDM_PUBLIC_HOSTS 白名单 + 只信任 Caddy 这一跳的 Host / X-Forwarded-Proto,挡 host header 注入。

Overview(为什么需要这套网关拓扑)

A-CDM 生产侧要同时满足五个看似冲突的诉求:

  1. 单一收敛入口——后端有 5 个容器(acdm-backend / deer-flow-gateway / deer-flow-langgraph / acdm-frontend / meeting-api),不可能每个都开公网端口。安全面必须收敛到一个进程上。
  2. 统一鉴权——这 5 个后端没有一个自己实现完整的 session 校验,不能让未登录请求直接打到它们。
  3. HTTPS 规范化——浏览器、cookie Secure 标记、OAuth redirect_uri 都要求一个稳定的 https:// 规范域名。
  4. 可回滚兜底——HTTPS 链路一旦 LE 证书 / Caddy 出问题,得有一条不依赖证书的应急通道,否则全站不可达。
  5. 防 host 注入——一旦同时接受多个 host(域名 + 两个 IP),OAuth redirect_uri 按请求 host 动态拼接就有被钓鱼的风险。

直觉做法是给每个后端单独配 Nginx + 各写一套 auth,但那样鉴权逻辑散在 5 处、证书管理复杂、回滚没有统一开关。A-CDM 的答案是:一个 Caddy 进程做 reverse proxy + forward_auth + TLS 终止 + host 收敛,鉴权委托给 acdm-backend 的一个 /auth/verify 端点,所有路由策略集中在一份 Caddyfile.keycloak-v1 里维护。

Architecture:双 server 块 + 一个 auth 委托端点

整个网关由一个 Caddy 容器 + 一份配置文件构成,关键参与方如下:

组件职责入口Source
platform-gateway(Caddy 2-alpine)唯一对外 HTTP/HTTPS 入口,反代 + TLS 终止 + forward_authcompose gateway 服务deploy/prod/platform/compose.yml:22-41
:80 server 块HTTP 入口:域名 301 跳 HTTPS,IP host 保留路由(应急兜底):80 { ... }deploy/prod/Caddyfile.keycloak-v1:13-148
acdm.r7.chinasoftinc.comHTTPS canonical 入口,LE 通配证书,路由表与 :80 完全一致acdm.r7... { ... }deploy/prod/Caddyfile.keycloak-v1:157-286
forward_auth 指令每条受保护路由前置,调 acdm-backend:8002/auth/verifyhandle 块内deploy/prod/Caddyfile.keycloak-v1:43-46
/auth/verify 端点解析 acdm_session cookie,200 注入 X-Auth-User-* / 401FastAPI routeracdm-backend/app/auth/verify.py:16-27
ACDM_PUBLIC_HOSTS 白名单多 host 模式下校验 request Host,挡 host 注入acdm-backend configacdm-backend/app/config.py:69-79
/opt/certs 证书挂载LE 通配 *.r7.chinasoftinc.com,运维侧 scp,只读挂载compose volumedeploy/prod/platform/compose.yml:34

设计上的关键决定:两个 server 块的路由表逐字复制而不用 import。文件头注释明确说这是 KISS 取舍——复制一份的维护成本(改一处要改两处)可接受,换来配置无宏展开、caddy validate 输出直观、出问题时 diff 一眼能看。

Components / Subsystems

全局块与 auto_https disable_redirects

文件最顶部只有一条全局选项:auto_https disable_redirects(deploy/prod/Caddyfile.keycloak-v1:3-5)。Caddy 默认行为是:只要你给某 server 块写了域名,它会自动注入一条 :80 → :443 的全量 redirect。这里主动关掉这个自动行为,因为 A-CDM 需要的不是"所有 :80 都跳 HTTPS",而是"只有域名 host 跳 HTTPS,IP host(172/101)仍然在 :80 上正常服务"——后者是应急回滚兜底,不能被自动 redirect 带走。这条注释在文件头写得很清楚(deploy/prod/Caddyfile.keycloak-v1:1-2)。

:80 块——HTTP 入口与应急兜底

职责:HTTP 进来的请求,域名跳 HTTPS,IP host 保留全套路由。

关键逻辑是开头的 host matcher(deploy/prod/Caddyfile.keycloak-v1:17-18):

caddy
@host_acdm host acdm.r7.chinasoftinc.com
redir @host_acdm https://acdm.r7.chinasoftinc.com{uri} permanent

@host_acdm 匹配 Host: acdm.r7.chinasoftinc.com,命中就 301 permanent 跳到同 path 的 HTTPS。redir 在所有 handle 块之前,优先生效——域名 HTTP 请求不会走到下面的路由。剩下没被这条 matcher 命中的(即 IP host 172.20.21.28 / 101.52.242.148)继续往下走完整路由表。

文件头注释点出一个关键现实:IP 路径虽然路由还在,但因为 acdm-backend 设了 COOKIE_SECURE=True,登录 cookie 只在 HTTPS 下发,所以 HTTP IP 路径的业务实际不可用,只有静态资源能加载(deploy/prod/Caddyfile.keycloak-v1:9-11)。它的价值是"HTTPS 链路彻底崩了能拿来排查 / 临时静态兜底",不是日常业务通道。

acdm.r7.chinasoftinc.com 块——HTTPS canonical 入口

职责:生产 canonical 入口,TLS 终止。

caddy
acdm.r7.chinasoftinc.com {
    tls /etc/caddy/certs/fullchain.pem /etc/caddy/certs/privkey.pem
    encode gzip zstd
    ...(与 :80 逐字相同的路由表)
}

tls 指向挂载进来的证书文件,而不是让 Caddy 自动签发——证书是 Let's Encrypt 通配 *.r7.chinasoftinc.com,由运维侧 scp 到宿主机 /opt/certs,compose 以 :ro 挂进容器 /etc/caddy/certs(deploy/prod/platform/compose.yml:34)。注释明确写了续期由运维管,Caddy 需 reload 或容器 restart 才重读新证书,本期不自动化(deploy/prod/Caddyfile.keycloak-v1:153-154)。这是个有意识的 tradeoff:用通配证书 + 手工挂载,避开 Caddy 在内网 VM 上跑 ACME HTTP-01 的复杂度,代价是续期需人工触发一次 reload。

forward_auth 委托与 /auth/verify

职责:把"这个请求有没有登录"这个判断,从 5 个后端各自实现收敛到一个端点。

每条受保护路由里都有同一段:

caddy
forward_auth acdm-backend:8002 {
    uri /auth/verify
    copy_headers X-Auth-User-Id X-Auth-User-Email X-Auth-User-Role X-Auth-User-Name
}

Caddy 收到请求后,先把它(连同 cookie)子请求打到 acdm-backend:8002/auth/verify。后端 verify()(acdm-backend/app/auth/verify.py:16-27)复用 get_current_user 解析 acdm_session cookie:

  • 校验通过 → 返回 200 并带 X-Auth-User-Id / Email / Name / Role 四个 header。Caddy 把这四个 header copy_headers 注入到原请求,再反代给真实后端。后端从此信任这四个 header(它们由 Caddy 加,客户端无法伪造)。
  • 校验失败get_current_user401(acdm-backend/app/util/auth.py:42-62:无 cookie / 过期 / 无效 / 用户不存在 / 已停用)。Caddy 把 401 原封返回客户端,前端 JS 跳 /auth/login

/auth/verify 端点本身极薄(acdm-backend/app/auth/verify.py,全文 27 行),把鉴权逻辑全复用 get_current_user,保证 forward_auth 这条路径和后端业务接口的 session 判断完全一致,不存在两套实现 drift。

forward_auth 路由矩阵(速查表)

两个 server 块路由表逐字相同,以下用 :80 块行号为准,:443 块为同名同结构(行号见 References)。关键看:是否过 forward_auth、strip 什么前缀、反代到哪个容器

路由 matcher路径模式forward_authstrip prefix反代目标flush -1(SSE)Source
@meeting_api/api/meeting/api/meeting/*/api/meetingmeeting-api:8000deploy/prod/Caddyfile.keycloak-v1:21-29
@acdm_auth_api/api/acdm/auth/api/acdm/auth/*✗(登录流自身)/api/acdmacdm-backend:8002deploy/prod/Caddyfile.keycloak-v1:32-38
@acdm_api/api/acdm/api/acdm/*/api/acdmacdm-backend:8002deploy/prod/Caddyfile.keycloak-v1:41-51
@deerflow_api/api/deerflow/api/deerflow/*/api/deerflowdeer-flow-gateway:8001deploy/prod/Caddyfile.keycloak-v1:54-64
@deerflow_bare_api`/api/modelsagentsskillsmcpmemorythreads(+/*`)
@langgraph_api/api/langgraph/api/langgraph/*/api/langgraphdeer-flow-langgraph:2024deploy/prod/Caddyfile.keycloak-v1:86-96
@a_cdm_fe/a-cdm/a-cdm/*不 stripacdm-frontend:3000deploy/prod/Caddyfile.keycloak-v1:99-104
@meeting_fe/meeting/meeting/*/meeting静态 /srv/www/meetingdeploy/prod/Caddyfile.keycloak-v1:107-116
@deerflow_docs_fe/deerflow-docs(+/*)/deerflow-docs静态 /srv/www/deerflow-docsdeploy/prod/Caddyfile.keycloak-v1:119-128
@erp_api/api/erp/api/erp/*/api/erpdemoo_acdm_agent:8088deploy/prod/Caddyfile.keycloak-v1:134-142
fallback handle {}其余全部redir * /a-cdm/ 302deploy/prod/Caddyfile.keycloak-v1:145-147

矩阵里三个最容易踩的点:

  1. @acdm_auth_api 必须排在 @acdm_api 前面(deploy/prod/Caddyfile.keycloak-v1:32 vs :41),且不带 forward_auth。原因:/api/acdm/auth/* 是登录入口,未登录用户访问它,如果先过 forward_auth 会直接 401,永远登录不了——这是个鸡生蛋问题。把它放最前 + 不鉴权,是唯一的鉴权豁免口。

  2. @deerflow_bare_api 是个"无前缀补丁",且故意不 strip(deploy/prod/Caddyfile.keycloak-v1:66-83)。注释解释 2026-05-12 的坑:deer-flow upstream 前端 getBackendBaseURL() 默认返回空串,导致前端一批 fetch 走相对路径 /api/models/api/agents 等(不带 /api/deerflow 前缀),原 Caddy 没匹配 → 落到 fallback 302 跳 /a-cdm/ → 前端 res.json() 抛错 → 思考模式开关、专家列表、技能页、MCP 配置、Memory 面板、附件上传、artifact 下载等悄无声息坏掉。补丁是单独建 matcher 把这批裸路径也接住,反代进 deer-flow-gateway:8001;因为该 gateway router 的 prefix 本身就是 /api/...,所以不能 strip(strip 了反而对不上)。

  3. @erp_api 2026-04-28 起也加了 forward_auth(deploy/prod/Caddyfile.keycloak-v1:130-142)。注释记录:挂上公网弹性 IP 101.52.242.148 后,原本"VPC 安全组限制源地址"的假设破了,所以 /api/erp/* 必须跟 /api/acdm/* 同款 forward_auth 保护。chrome extension 不走这条(它直连 host:8088),不受影响。

Data Flow:一个请求从浏览器到后端

每步说明:

  1. 浏览器用 HTTP 访问域名某 API。
  2. Caddy :80@host_acdm matcher 命中 Host: acdm.r7.chinasoftinc.com
  3. redir ... permanent 返回 301,带 {uri} 保持原路径。
  4. 浏览器重发 HTTPS 请求,自动带上 acdm_session cookie(COOKIE_SECURE=True 下 cookie 只在 HTTPS 发)。
  5. acdm.r7... 块用 /etc/caddy/certs 证书做 TLS 终止,@deerflow_api matcher 命中。
  6. forward_auth 把请求子转发到 acdm-backend:8002/auth/verify,带原 cookie。
  7. cookie 有效:verify() 返回 200 + 四个 X-Auth-User-* header。
  8. Caddy copy_headers 把这四个 header 写进原请求,uri strip_prefix /api/deerflow 砍掉前缀。
  9. reverse_proxydeer-flow-gateway:8001,flush_interval -1 关闭缓冲(SSE 长连必需)。
  10. cookie 无效:get_current_user 抛 401,Caddy 原样返客户端,前端跳登录页。

Implementation Details:多 host 防注入的关键算法

多 host 模式(域名 + 两个 IP 都接受)带来一个真实安全风险:OAuth 的 redirect_uri 如果按"请求里的 host"动态拼,攻击者送一个 Host: evil.com 就能让后端生成指向 evil.com 的 redirect_uri 钓鱼。后端的防御有两道:

第一道:只信任 Caddy 这一跳的 Host,绝不读 X-Forwarded-Host

python
# 摘自 acdm-backend/app/auth/router.py:69-78
def _request_host(request: Request) -> str:
    # **不**读 X-Forwarded-Host:那是客户端可控字段
    # Caddy reverse_proxy 默认用真实 Host header 覆写 X-Forwarded-Host
    host = request.headers.get("host") or ""
    return host.split(":", 1)[0]

X-Forwarded-Host 是客户端可控的;而 Caddy reverse_proxy 默认会用真实 Host header 覆写它,所以从后端视角,Host header 才是用户浏览器 URL 栏里那个真实 host。scheme 同理,只信 Caddy 主动设的 X-Forwarded-Proto(acdm-backend/app/auth/router.py:81-83)。

第二道:host 必须在 ACDM_PUBLIC_HOSTS 白名单内,否则 fail-fast 400

python
# 摘自 acdm-backend/app/auth/router.py:86-91
def _validate_host(host: str) -> None:
    if host not in _allowed_hosts():
        raise HTTPException(status_code=400, detail=f"Host '{host}' not in allowed list")

_allowed_hosts()ACDM_PUBLIC_HOSTS(逗号分隔)拆成 set(acdm-backend/app/auth/router.py:63-66)。ACDM_PUBLIC_HOSTS 空(默认)= 关闭多 host,redirect_uri 退回静态 KEYCLOAK_REDIRECT_URI(向后兼容);非空才启用动态构造 + 白名单校验(acdm-backend/app/config.py:69-79)。这是个分层兜底:即使白名单配错,空值也只是退回单 host 静态行为,不会暴露注入面。

速率限制的 key 函数也有个 Caddy 相关的坑:acdm-backend 跑在 Caddy 反代后,request.client.host 永远是 Caddy 容器在 docker network 里的 IP(所有用户同一个)。如果按它做 SlowAPI key,等于全局限流。正确做法是读 X-Forwarded-For 第一个值(Caddy 这一跳设的真实客户端 IP):

python
# 摘自 acdm-backend/app/util/rate_limit.py:23-30
def _get_real_ip(request: Request) -> str:
    xff = request.headers.get("x-forwarded-for") or ""
    if xff:
        return xff.split(",", 1)[0].strip()
    ...

/auth/login 上挂 @limiter.limit(settings.ACDM_LOGIN_RATE_LIMIT)(默认 30/minute,acdm-backend/app/auth/router.py:136-137),公网暴露后防扫描脚本刷登录。

Configuration

Config默认值含义影响Source
auto_https disable_redirects启用关掉 Caddy 自动 :80→:443 redirect让 IP host 在 :80 保留服务deploy/prod/Caddyfile.keycloak-v1:3-5
tls /etc/caddy/certs/*.pem手工挂载LE 通配证书路径续期后需 reload/restart 才生效deploy/prod/Caddyfile.keycloak-v1:158
ports 80:80 / 443:443都开唯一对外端口外网暴露面仅这两个deploy/prod/platform/compose.yml:26-28
/opt/certs:/etc/caddy/certs:ro只读挂载宿主证书目录映射进容器运维 scp 续期deploy/prod/platform/compose.yml:34
ACDM_PUBLIC_HOSTS ⚠️安全""(关闭)OAuth host 白名单(逗号分隔)空=单 host 静态;非空=多 host+白名单防注入acdm-backend/app/config.py:69-79
COOKIE_SECURE ⚠️安全False(dev)session cookie 是否 Secure生产 True → HTTP IP 路径业务不可用(预期)acdm-backend/app/config.py:61-64
ACDM_RATE_LIMIT_ENABLEDTrueSlowAPI 限流总开关False 紧急救场;改后须 force-recreateacdm-backend/app/config.py:81-87
ACDM_LOGIN_RATE_LIMIT30/minute/auth/login 每 IP 限流拦扫描脚本(通常 100+ rps)acdm-backend/app/config.py:88-94

紧急降级开关组合(改 .env必须 docker compose up -d --force-recreate,restart 不重读 .env):ACDM_PUBLIC_HOSTS=(退回单 host)、COOKIE_SECURE=False(让 HTTP IP 路径业务可用)、ACDM_RATE_LIMIT_ENABLED=False(关限流)。

Common Pitfalls / 实战 Tips

从配置文件注释和 deploy-topology runbook 读出的真实坑:

  • Caddyfile 改动只能 restart gateway,不能 reload(deploy/prod/platform/compose.yml:16-19)。Docker bind mount 按 inode 挂载,scp 替换文件会换 inode,caddy reload 读到的还是旧 inode 内容。标准 runner 是 deploy/prod/switch-caddyfile.sh,自动备份 + 上传 + restart + 回归 curl。
  • 禁止在服务器上直接 sed/nano 改 Caddyfile——同 inode 问题。流程必须是本地改 → caddy validate 离线校验 → scp → restart。
  • 两个 server 块要同步改::80acdm.r7... 路由表逐字复制,加一条路由要在两个块各加一遍,漏一个会导致"HTTPS 能用 HTTP 不能"或反之的诡异现象。
  • 证书续期不自动:/opt/certs 续期后必须 docker exec platform-gateway caddy reloadrestart 容器,否则用旧证书直到过期(deploy/prod/Caddyfile.keycloak-v1:154)。
  • HTTP IP 路径"业务不可用"是预期不是 bug:COOKIE_SECURE=True 下 cookie 不在 HTTP 发,IP 路径只能加载静态资源。需要它当真兜底时,得同时把 COOKIE_SECURE=False 并 force-recreate。
  • 加路由要排在 fallback handle {} 之前:fallback 是 redir * /a-cdm/ 302,任何没被前面 matcher 命中的路径都会被它吞掉跳首页(@deerflow_bare_api 那个补丁就是被这条坑出来的)。

References

  • deploy/prod/Caddyfile.keycloak-v1:1-148 — :80 块:全局选项 + 域名 301 + 全套 handle 路由(本章主源)
  • deploy/prod/Caddyfile.keycloak-v1:150-286acdm.r7.chinasoftinc.com HTTPS 块,tls + 与 :80 逐字相同路由表
  • deploy/prod/platform/compose.yml:21-64 — platform-gateway 容器定义,80/443 端口、Caddyfile + 证书挂载、restart 姿势注释
  • acdm-backend/app/auth/verify.py:1-27 — forward_auth 委托端点,200+X-Auth-User-* / 401
  • acdm-backend/app/util/auth.py:37-62get_current_user 解析 acdm_session cookie,401 各分支
  • acdm-backend/app/auth/router.py:63-137 — 多 host 白名单 / Host 取值防注入 / 速率限制装饰器
  • acdm-backend/app/config.py:61-94 — COOKIE_SECURE / ACDM_PUBLIC_HOSTS / 限流配置项
  • acdm-backend/app/util/rate_limit.py:1-36 — SlowAPI Limiter,X-Forwarded-For 取真实 IP
PageRelationship
鉴权与授权双层体系本章 forward_auth 调的 /auth/verify 与 cookie 校验在该章详解
请求生命周期与服务拓扑本章是该章端到端请求链路的网关入口环节
环境变量凭据与降级开关本章用到的 ACDM_PUBLIC_HOSTS/COOKIE_SECURE/限流开关在该章统一说明
CICD 与生产部署运维本章 Caddyfile 切换 5 步铁律是该章部署流程的一部分
proxy 鉴权守卫与 API 反代本章网关侧鉴权与该章前端 proxy 侧守卫互补构成双层防护

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析