入站客户端 token
默认情况下 shunt 没有入站认证 —— 对于一个只在回环的个人网关这没问题,但一旦你通过 VPN/隧道共享它,任何能触达它的人都能在映射模型上花费运营者的账户(shunt 为这些模型注入它自己的 api_key/chatgpt_oauth 凭据)。透传模型不在此列:它们转发每个调用方自己的 Anthropic 凭据。
[server.auth] 用按客户端 token 门控注入凭据的路由和模型发现:
[server.auth] # 两个键都可选;显示的是默认值
header = "x-shunt-token"
tokens_env = "SHUNT_CLIENT_TOKENS"# 网关侧:name:token 对(name 是用于日志的标签;token 是密钥)
export SHUNT_CLIENT_TOKENS="minsu:$(openssl rand -hex 32),alice:$(openssl rand -hex 32)"如果存在 [server.auth] 但环境变量未设置或格式错误,启动会安全失败(fail closed)。对映射模型和 GET /v1/models 的、不带有效 token 的请求会得到 401 authentication_error。两个门控都接受客户端 token 出现在任意标准 Anthropic 凭据槽位中 —— 当多个槽位都携带有效 token 时,优先级依次为:配置的头部(默认 x-shunt-token)、Authorization: Bearer、x-api-key。GET /routes、GET|HEAD /、GET /health 以及透传模型保持开放。GET /routes 无需认证,因为它是暴露路由元数据(配置的提供方/上游模型映射)的 shunt 原生端点,从不暴露凭据 —— 凭据只存在于提供方配置中,且从不被该处理器读取。
在被门控的路由上,被接受的凭据头部在转发前总会被剥除(shunt 会在那里注入它自己的提供方凭据),匹配是常量时间的,token 值从不被记录(客户端名称会,按请求)。在透传路由上 shunt 不注入任何东西,因此调用方自己的凭据会按呈现的样子转发 —— 但按槽位有例外:当 Authorization 或 x-api-key 槽位自身的值是 shunt 自己的入站凭据(被接受的客户端 token、[server.gateway] JWT,或 [server.admin] 凭据 —— 管理面除了自己的头部之外也在 x-api-key 中接受它)时,该槽位会被清除。因此在门控处被接受的凭据绝不会被中继到上游,而另一个槽位中真实的上游凭据仍会继续流动。每个槽位会针对它携带的每一个值进行判定,而不只是第一个;只要某个位置上存在 shunt 的凭据,该槽位就会被整个丢弃 —— 因此如果你把同一个头部发送两次,一个值放真实凭据、另一个值放 shunt 凭据,两者都会消失。cookie 头部在每次转发时都会被整个移除 —— 管理面在该处接受会话 cookie,而 shunt 自身不保留 cookie jar。代价是你发送的无害 cookie 也会一并被丢弃。
客户端侧,按网关提供的内容来选择:
-
仅池化/映射的网关(例如以 Anthropic 账户池作为默认提供方):客户端 token 可以直接就是 Claude Code 已经在发送的凭据 —— 无需额外的头部行:
export ANTHROPIC_AUTH_TOKEN="<your client token>" # 以 Authorization: Bearer 发送 -
混有透传模型时:每个调用方真实的 Anthropic 凭据需要一个自己的槽位,因此请通过配置的头部分发专用 token,并把
Authorization/x-api-key留给它(ANTHROPIC_CUSTOM_HEADERS每行接受一个Name: Value)。这是卫生做法,而非防止泄露的唯一手段:确实出现在Authorization或x-api-key中的客户端 token ——[server.gateway]JWT 和[server.admin]凭据同样如此 —— 会在任何透传转发之前按值从该槽位清除。export ANTHROPIC_CUSTOM_HEADERS="x-shunt-token: <your token>"
如果你还启用了可选的管理 Web 界面,请用独立的管理员 token 保护它,并且只通过 HTTPS 或可信隧道暴露。
验证自有 identity provider 签发的 JWT
如果你的组织已经在运行 identity provider,[[server.auth.jwt]] 可以让 shunt 接受它签发的 token,而不必分发共享 secret —— 在不让 shunt 变成认证服务器的前提下获得按用户区分的身份。shunt 只做验证:没有登录流程、没有会话存储、没有签名 secret。与 gateway 登录不同,客户端仍然只需要配置 base URL 和 bearer,因此不会进入 Claude Code 的 gateway provider 模式,也不会失去该模式禁用的功能。
[server.auth]
# 配置了 issuer 之后就不再需要静态 token。
tokens_env = "SHUNT_CLIENT_TOKENS"
[[server.auth.jwt]]
issuer = "https://accounts.google.com"
audience = ["<你为 shunt 创建的 client id>"]
email_domains = ["example.com"]每个调用方发送 token 的位置和静态 token 完全一样:
export ANTHROPIC_AUTH_TOKEN="<IdP 签发的 JWT>" # 作为 Authorization: Bearer 发送JWT 只通过 Authorization: Bearer 接受 —— 配置的 header 和 x-api-key 仍然只接受静态 token。两种方式可以同时配置,静态 token 会先被检查。
配置时有两点需要注意:
- 给 shunt 单独的 audience。 为 shunt 单独创建一个 client id 并写入
audience,这样同一租户中为其他应用签发的 token 就不会在这里通过认证。 - 签发短寿命 token。 shunt 不保存吊销状态,所以 token 会一直可用到过期。
max_token_age_seconds(默认3600)会拒绝寿命超过该值的 token —— 如果需要更严格的吊销,请调小它并让 IdP 签发分钟级的 token。
条目配置错误时启动会 fail closed,包括只有 audience 而没有 email_domains / allowed_emails 规则的情况。如果 JWT 认证返回带有 api_error 正文的 503(而不是下文容量上限拒绝时的 overloaded_error),说明 shunt 没能拉取到 issuer 的 key set —— 这时应该检查 issuer URL 和网络可达性,而不是凭据。完整的键列表见配置参考。
SSE keepalive ping
中间盒会中断安静的流 —— Cloudflare 的代理在100 秒无字节后返回 524(Enterprise 以下固定如此),而长时间的推理过程可能安静那么久。因此,每当一个流式响应闲置时,shunt 会注入 Anthropic 协议自身的 ping 事件(api.anthropic.com 自己也会发出它,而每个客户端都会忽略它):
[server]
sse_keepalive_seconds = 30 # 默认;0 禁用ping 只在完整的 SSE 事件之间注入(绝不在半发出的帧内部),只在 text/event-stream 响应上注入,并随上游流停止。在一个没有闲置超时的隧道后(WireGuard/Tailscale),这些 ping 是无害的;如果你想要逐字节相同的中继,用 0 禁用。
入站并发上限
共享网关会同时服务多个客户端,因此 shunt 会限制同时处理中的入站请求数量:
[server]
max_concurrent_requests = 1024 # 默认;0 禁用限制超出上限时,shunt 不会排队,而是立即以 503 和 Retry-After: 1 拒绝。排队的请求同样会把已缓冲的请求体留在内存里,而这个上限正是为了防止这种增长。流式响应会一直占用名额,直到响应正文结束或客户端断开。也就是说,长时间运行的流在整个生命周期内都计入上限,而不只是在生成响应头的那一刻。
GET / 和 GET /health 不受限制,所以即使网关已经饱和,它仍会回应存活探测,负载均衡器也不会摘除一个正在处理真实工作的实例。
默认值足够高,个人或小团队网关不会触及。繁忙的共享部署可以调高该值,设为 0 则恢复无限并发。该上限在启动时固定:修改它会被 config reload 接受,但需要重启才会生效。
如果客户端报告带有 overloaded_error 正文的 503,说明网关已达上限 —— 请调高 max_concurrent_requests,或检查是否有卡住的长连接流一直占着名额。 被拒绝的请求会递增 shunt.requests_shed 计数器,拒绝本身以 debug! 记录。
这个上限计的是请求数而不是字节数,而且 permit 会在请求处理期间一直被占用。面向公网的部署需要注意两点:
- 每个被放行的请求最多可能缓冲 64 MiB 的请求体,所以字节预算会随上限成比例增长。如果主机吃不下这个乘积,请调低
max_concurrent_requests。 - 缓慢发送请求体的客户端,或者卡住的上游流,会在此期间一直占着名额;shunt 不施加入站请求超时。如果网关对外暴露,请在边缘代理上切断慢客户端。
这两点都不是本上限引入的新问题 —— 恰恰相反,上限让原本无限的天花板变得有限。但这意味着仅靠 max_concurrent_requests 并不构成内存上限。