主题
Caddy 网关与生产路由拓扑
本章目标:
- 看懂
Caddyfile.keycloak-v1的 :80 / :443 双 server 块如何分工:HTTPS canonical 入口 + IP 应急兜底- 掌握 forward_auth 矩阵——哪些
/api/*路由过鉴权、哪些(登录流)不过、各自的 strip 规则- 理解 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 生产侧要同时满足五个看似冲突的诉求:
- 单一收敛入口——后端有 5 个容器(acdm-backend / deer-flow-gateway / deer-flow-langgraph / acdm-frontend / meeting-api),不可能每个都开公网端口。安全面必须收敛到一个进程上。
- 统一鉴权——这 5 个后端没有一个自己实现完整的 session 校验,不能让未登录请求直接打到它们。
- HTTPS 规范化——浏览器、cookie
Secure标记、OAuth redirect_uri 都要求一个稳定的https://规范域名。 - 可回滚兜底——HTTPS 链路一旦 LE 证书 / Caddy 出问题,得有一条不依赖证书的应急通道,否则全站不可达。
- 防 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_auth | compose 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.com 块 | HTTPS canonical 入口,LE 通配证书,路由表与 :80 完全一致 | acdm.r7... { ... } | deploy/prod/Caddyfile.keycloak-v1:157-286 |
forward_auth 指令 | 每条受保护路由前置,调 acdm-backend:8002/auth/verify | 各 handle 块内 | deploy/prod/Caddyfile.keycloak-v1:43-46 |
/auth/verify 端点 | 解析 acdm_session cookie,200 注入 X-Auth-User-* / 401 | FastAPI router | acdm-backend/app/auth/verify.py:16-27 |
ACDM_PUBLIC_HOSTS 白名单 | 多 host 模式下校验 request Host,挡 host 注入 | acdm-backend config | acdm-backend/app/config.py:69-79 |
/opt/certs 证书挂载 | LE 通配 *.r7.chinasoftinc.com,运维侧 scp,只读挂载 | compose volume | deploy/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 把这四个 headercopy_headers注入到原请求,再反代给真实后端。后端从此信任这四个 header(它们由 Caddy 加,客户端无法伪造)。 - 校验失败 →
get_current_user抛401(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_auth | strip prefix | 反代目标 | flush -1(SSE) | Source |
|---|---|---|---|---|---|---|
@meeting_api | /api/meeting、/api/meeting/* | ✓ | /api/meeting | meeting-api:8000 | deploy/prod/Caddyfile.keycloak-v1:21-29 | |
@acdm_auth_api | /api/acdm/auth、/api/acdm/auth/* | ✗(登录流自身) | /api/acdm | acdm-backend:8002 | ✓ | deploy/prod/Caddyfile.keycloak-v1:32-38 |
@acdm_api | /api/acdm、/api/acdm/* | ✓ | /api/acdm | acdm-backend:8002 | ✓ | deploy/prod/Caddyfile.keycloak-v1:41-51 |
@deerflow_api | /api/deerflow、/api/deerflow/* | ✓ | /api/deerflow | deer-flow-gateway:8001 | ✓ | deploy/prod/Caddyfile.keycloak-v1:54-64 |
@deerflow_bare_api | `/api/models | agents | skills | mcp | memory | threads(+/*`) |
@langgraph_api | /api/langgraph、/api/langgraph/* | ✓ | /api/langgraph | deer-flow-langgraph:2024 | ✓ | deploy/prod/Caddyfile.keycloak-v1:86-96 |
@a_cdm_fe | /a-cdm、/a-cdm/* | ✗ | 不 strip | acdm-frontend:3000 | deploy/prod/Caddyfile.keycloak-v1:99-104 | |
@meeting_fe | /meeting、/meeting/* | ✗ | /meeting | 静态 /srv/www/meeting | deploy/prod/Caddyfile.keycloak-v1:107-116 | |
@deerflow_docs_fe | /deerflow-docs(+/*) | ✗ | /deerflow-docs | 静态 /srv/www/deerflow-docs | deploy/prod/Caddyfile.keycloak-v1:119-128 | |
@erp_api | /api/erp、/api/erp/* | ✓ | /api/erp | demoo_acdm_agent:8088 | deploy/prod/Caddyfile.keycloak-v1:134-142 | |
fallback handle {} | 其余全部 | ✗ | — | redir * /a-cdm/ 302 | deploy/prod/Caddyfile.keycloak-v1:145-147 |
矩阵里三个最容易踩的点:
@acdm_auth_api必须排在@acdm_api前面(deploy/prod/Caddyfile.keycloak-v1:32vs:41),且不带 forward_auth。原因:/api/acdm/auth/*是登录入口,未登录用户访问它,如果先过 forward_auth 会直接 401,永远登录不了——这是个鸡生蛋问题。把它放最前 + 不鉴权,是唯一的鉴权豁免口。@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 了反而对不上)。@erp_api2026-04-28 起也加了 forward_auth(deploy/prod/Caddyfile.keycloak-v1:130-142)。注释记录:挂上公网弹性 IP101.52.242.148后,原本"VPC 安全组限制源地址"的假设破了,所以/api/erp/*必须跟/api/acdm/*同款 forward_auth 保护。chrome extension 不走这条(它直连 host:8088),不受影响。
Data Flow:一个请求从浏览器到后端
每步说明:
- 浏览器用 HTTP 访问域名某 API。
- Caddy
:80块@host_acdmmatcher 命中Host: acdm.r7.chinasoftinc.com。 redir ... permanent返回 301,带{uri}保持原路径。- 浏览器重发 HTTPS 请求,自动带上
acdm_sessioncookie(COOKIE_SECURE=True下 cookie 只在 HTTPS 发)。 acdm.r7...块用/etc/caddy/certs证书做 TLS 终止,@deerflow_apimatcher 命中。forward_auth把请求子转发到acdm-backend:8002/auth/verify,带原 cookie。- cookie 有效:
verify()返回 200 + 四个X-Auth-User-*header。 - Caddy
copy_headers把这四个 header 写进原请求,uri strip_prefix /api/deerflow砍掉前缀。 reverse_proxy到deer-flow-gateway:8001,flush_interval -1关闭缓冲(SSE 长连必需)。- 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_ENABLED | True | SlowAPI 限流总开关 | False 紧急救场;改后须 force-recreate | acdm-backend/app/config.py:81-87 |
ACDM_LOGIN_RATE_LIMIT | 30/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 改动只能
restartgateway,不能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 块要同步改:
:80和acdm.r7...路由表逐字复制,加一条路由要在两个块各加一遍,漏一个会导致"HTTPS 能用 HTTP 不能"或反之的诡异现象。 - 证书续期不自动:
/opt/certs续期后必须docker exec platform-gateway caddy reload或restart容器,否则用旧证书直到过期(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-286—acdm.r7.chinasoftinc.comHTTPS 块,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-* / 401acdm-backend/app/util/auth.py:37-62—get_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
Related Pages
| Page | Relationship |
|---|---|
| 鉴权与授权双层体系 | 本章 forward_auth 调的 /auth/verify 与 cookie 校验在该章详解 |
| 请求生命周期与服务拓扑 | 本章是该章端到端请求链路的网关入口环节 |
| 环境变量凭据与降级开关 | 本章用到的 ACDM_PUBLIC_HOSTS/COOKIE_SECURE/限流开关在该章统一说明 |
| CICD 与生产部署运维 | 本章 Caddyfile 切换 5 步铁律是该章部署流程的一部分 |
| proxy 鉴权守卫与 API 反代 | 本章网关侧鉴权与该章前端 proxy 侧守卫互补构成双层防护 |