边缘与控制台路由
HybridInference 会跑两个长期运行的 HTTP 服务:FastAPI 网关(apps/backend)和 Next.js 控制台(apps/frontend)。其中只有一个需要能从公网访问到。
公开路径表由控制台掌管。它是终结公网流量的那个进程,它在 apps/frontend/next.config.js 里的 rewrites() 配置决定了哪些路径被代理给 FastAPI(或另一个服务)、哪些由它自己回答。没有在那里写明的路径,一律由控制台自己的页面提供。
这一点值得明说,因为从外面看很容易搞错:加一条公开路由是改 next.config.js,而不是改前面碰巧挡着的那层反向代理或隧道。那个文件里没有的路径,会由控制台的 HTML 404 页面来回答——在 API 客户端看来,这读起来像「网关挂了」,而不是「这个路径没有被转发」。
client ──▶ (your edge: CDN / tunnel / reverse proxy)
│
▼
Next.js console ──┬──▶ FastAPI gateway (rewrites, table below)
├──▶ cloud agent (rewrites, when configured)
├──▶ pgAdmin (route handler, admin-gated)
└──▶ its own pages (everything else)
路径表
下面的表格由 apps/frontend/next.config.js 中的 rewrites() 函数生成。那个文件才是事实来源;两者不一致时以文件为准。改了它就重新生成本节。
目标主机来自环境变量。Next 在构建时解析 rewrites() 并把结果写进 .next/routes-manifest.json,所以这些是构建参数,而不是运行时环境:只在 docker run 时提供的值不会被任何东西读到,症状是 docker inspect 里每个变量看着都设对了,服务的却仍是旧表。见 deploy/docker/docker-compose.yml 中 frontend 服务的 build.args。
变量 |
默认值 |
指向 |
|---|---|---|
|
|
FastAPI 网关 |
|
(未设置) |
独立部署的 cloud agent 的 web 应用 |
|
(未设置) |
那个 agent 的控制面 API |
|
|
pgAdmin(由 route handler 使用,不是 rewrite) |
beforeFiles——cloud agent 代理
只有在 AGENT_WEB_INTERNAL_URL 和 AGENT_CONTROL_PLANE_INTERNAL_URL 都设置了的时候才会生成。任一未设置,这个数组就是空的,/agents 根本没有路由。
源路径 |
目标 |
说明 |
|---|---|---|
|
|
前缀被去掉——控制面在自己的根路径上提供这些路由 |
|
|
前缀被保留——那个应用是以 |
|
|
裸前缀 |
这里有两条关于顺序的事实是关键的:
/agents/api/:path*必须排在/agents/:path*前面。前者是后者的前缀,顺序反过来的话,每个 API 调用都会被 web 应用的 HTML 应答。这些是
beforeFiles,不是一个扁平数组。以扁平数组返回的 rewrite 属于afterFiles,Next 会在文件系统路由之后才检查它——beforeFiles则让这个代理保持权威,即便之后在该前缀下出现了某个页面。
afterFiles——网关
下面每一条的目标都是 ${BACKEND_INTERNAL_URL} 加上同样的路径。
源路径 |
服务的内容 |
|---|---|
|
OpenAI 兼容的 API 接口 |
|
Anthropic Messages 接口 |
|
认证路由 |
|
用户仪表盘 API |
|
管理员 API |
|
(见下方说明) |
|
基于 cookie 会话的管理员校验(由 pgAdmin handler 使用) |
|
控制台仪表盘调用的、仅管理员可用的模型 playground |
|
读取模型目录 |
|
读取单个用户的状态 |
|
签发一个推理 grant( |
|
对单个 grant 的续期 / 用量 / 吊销 |
|
健康检查 |
|
公开的站点横幅 |
|
公开的部署身份,由控制台的 |
关于 /internal 的说明:这些条目是一条条单独列出的,而不是用一条笼统的 /internal/:path* 转发,这是有意的。这个前缀是共用的——/internal/verify-* 用 cookie 认证浏览器会话——所以一条笼统的规则会把下一个落到 /internal 下的路由直接公开出去,而没有任何人决定过它应该能从外部访问。/internal/agent-grants 是唯一被授予整个子前缀的例外,因为那个 router 上的每条路由都在 router 层带了 dispatch token 依赖,因此在构造上就是已鉴权的。
关于 /internal/verify-grafana 的说明:这条 rewrite 存在,但在当前修订版的 apps/backend 里并没有注册与该路径匹配的路由。它转发过去只会得到网关的 404。
为什么 /pgadmin 是 route handler 而不是 rewrite
/pgadmin 是这里有意思的一个例子,也是本页存在的理由。
pgAdmin 必须只能被管理员访问到。rewrite 无法鉴权——它是一个静态映射,在你的任何代码运行之前就被求值,没有办法向外发起调用、检查会话或者拒绝请求。所以 /pgadmin 根本不在上面的表里。它是一条应用路由 apps/frontend/src/app/pgadmin/[[...path]]/route.ts,属于文件系统路由,因此优先于 afterFiles 里的 rewrite。这个 handler 在检查过调用方之后,自己代理到 pgAdmin。
这个 handler 是一个紧凑的实例,示范了如何在 Next.js 的 route handler 里写一个带鉴权的反向代理,而且它的每一个决定都可以推广:
失败即拒绝(fail closed)。
verifyAdmin()带着调用方的 cookie、以 5 秒超时调用网关上的GET /internal/verify-admin。只有明确的200才放行。后端挂了、慢了、或者答了点意料之外的东西,都拒绝。它假定自己是挡在一个数据库控制台前面的唯一一道关,因为它无法判断 pgAdmin 是否有自己的登录(那取决于PGADMIN_CONFIG_SERVER_MODE,其默认值是False)。转发前剥掉控制台自己的会话 cookie。pgAdmin 用不上它,而把会话凭据转发给被代理的应用,正是这类凭据泄漏的方式。
丢掉逐跳(hop-by-hop)头(RFC 9110 §7.6.1),外加
host和content-length——这两个fetch会自己从待发出的请求推导。回程要丢掉content-encoding,因为fetch已经把 body 解压过了。用getSetCookie()重新拆分Set-Cookie——Headers.forEach会把重复的值折成一个字符串。把上游给出的绝对地址重定向折回成裸路径。当请求少了某条路由要求的尾斜杠时,Werkzeug 会用它看到的
Host构造一个绝对重定向——这里就是内部容器名,在浏览器里根本解析不了。foldUpstreamRedirect()会把这类地址改写成路径,它匹配的是 hostname 而不是 origin,所以 URL 上带的是什么端口它都能兜住。重定向要用裸路径,而不是绝对 URL。在隧道后面,应用把自己的绑定地址当成
Host,于是new URL('/login', request.nextUrl)会渲染成https://0.0.0.0:3001/login,把浏览器晾在那儿。由于NextResponse.redirect()只接受绝对 URL,这个 handler 是自己手写Location头的。
与尾斜杠的相互作用
skipTrailingSlashRedirect: true 是在 next.config.js 里全局设置的,而且为了这一条路径,它必须这么设。Next 会用重定向把尾斜杠规范化掉;pgAdmin(Flask)又会用同样的方式把它加回来。开着它,第一次有人打开 /pgadmin/browser/ 时,两边就会让浏览器在它们之间无限弹跳。被代理的路径必须原样、按浏览器请求的样子送到 pgAdmin。
把这个开关全局关掉会改变站点上所有其他 URL,所以 apps/frontend/src/middleware.ts 为 /pgadmin 前缀以外的所有路径重新实现了这个重定向——用 308 跳到去掉尾斜杠的路径,并且是用 new URL(request.url) 而不是 nextUrl.clone() 构造的(克隆出来的 NextURL 会记住请求里带的尾斜杠并重新序列化出来,把请求重定向到它已经所在的地方)。
新增一条公开路径
在
apps/frontend/next.config.js的rewrites()里加上规则。除非它必须压过文件系统路由,否则放进afterFiles。写得具体一点。优先用
/prefix/thing而不是/prefix/:path*,除非该前缀下当前和将来的每一条路由在构造上都是已鉴权的。如果这条路径需要一次目标服务自己做不了的检查,那它就该是 route handler,而不是 rewrite。
重新构建控制台。rewrite 表在构建时就被烤进了
.next/routes-manifest.json,所以只部署后端不会带上这个改动。启用或回滚 cloud agent 代理同样是一次重新构建,而不是改一下环境变量。重新生成上面的表格。