diff --git a/docker/README.md b/docker/README.md index f63fc39c..4c7db943 100644 --- a/docker/README.md +++ b/docker/README.md @@ -21,7 +21,7 @@ Nginx routes: | Public route | Purpose | | --- | --- | -| `/` and `/pages/home/index.html` | Browser management UI. `/` redirects to a URL containing the management token. | +| `/` and `/pages/home/index.html` | Browser management UI. `/` redirects to the page and keeps a `ccr_web_token` query parameter if the request has one. The token is never added by Nginx. | | `/api/ccr/rpc` | Authenticated management RPC. | | `/health` | Gateway health, not container/UI health. It can return `502` until a provider and model are configured and the gateway starts. | | `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Supported model and MCP gateway requests. | @@ -35,7 +35,7 @@ npm run docker:compose:up docker compose logs -f ccr ``` -Open . On a new volume, the management UI is immediately available. Add a provider and model, create a CCR client key under **API Keys**, and start the gateway from **Server**. +Open the management URL that the entrypoint prints in the container log, `http://127.0.0.1:3458/?ccr_web_token=`. It is only printed when the token was generated; if you set `CCR_WEB_AUTH_TOKEN` yourself, add it to the URL. On a new volume, the management UI is immediately available. Add a provider and model, create a CCR client key under **API Keys**, and start the gateway from **Server**. The npm Compose script prepares a local `../../next-ai/gateway` checkout before building so the image uses that plugin-capable ai-gateway runtime. If you run `docker compose up -d --build` directly, run `npm run docker:prepare-gateway` first. `npm run docker:build` performs the same preparation automatically. @@ -82,10 +82,10 @@ npm run docker:run There are two independent authentication layers: -1. `CCR_WEB_AUTH_TOKEN` protects management RPC. Nginx puts it into the management-page URL, and the browser sends it to RPC as `x-ccr-web-auth`. +1. `CCR_WEB_AUTH_TOKEN` protects management RPC. You open the management page with it in the URL (`/?ccr_web_token=`), and the browser sends it to RPC as `x-ccr-web-auth`. Nginx never sends it to a client. 2. CCR client API keys created in the **API Keys** page protect model gateway requests. These are separate from upstream provider credentials. -If `CCR_WEB_AUTH_TOKEN` is unset, the entrypoint generates a new random token on each container start. Opening `/` still works because Nginx redirects to a tokenized URL, but a stable token is recommended for persistent or remote deployments. +If `CCR_WEB_AUTH_TOKEN` is unset, the entrypoint generates a new random token on each container start and prints the management URL once to the container log. Opening `/` without a token loads the page, but every management call fails with `401` until the token is supplied. The page keeps the token for the browser tab session. A stable token is recommended for persistent or remote deployments. Avoid putting the token directly in shell history. Create a protected environment file instead: @@ -237,7 +237,7 @@ docker compose config ### `/` returns `302` -This is expected. Nginx redirects the root URL to the management page and URL-encodes the management token. +This is expected. Nginx redirects the root URL to the management page and keeps the query string, so a `ccr_web_token` you supply survives. It does not add a token. ### `/health` returns `502` @@ -245,7 +245,7 @@ This is expected. Nginx redirects the root URL to the management page and URL-en ### The UI returns `401` after a token change -Open the bare root URL again so Nginx creates a URL with the current token. Close stale tabs and avoid bookmarks that contain an old `ccr_web_token`. +Open `/?ccr_web_token=` again with the current token (for a generated token, read it from `docker compose logs ccr`). Close stale tabs and avoid bookmarks that contain an old `ccr_web_token`. ### Clients still use the old port or hostname @@ -278,7 +278,7 @@ npm run docker:compose:up docker compose logs -f ccr ``` -打开 。首次启动时管理 UI 可以立即访问;添加供应商和模型、在 **API 密钥** 页面创建 CCR 客户端 Key,然后从 **服务** 页面启动网关。 +打开容器日志中打印的管理地址 `http://127.0.0.1:3458/?ccr_web_token=`(仅在 Token 自动生成时打印;自行设置了 `CCR_WEB_AUTH_TOKEN` 时,请自行拼到地址后)。首次启动时管理 UI 可以立即访问;添加供应商和模型、在 **API 密钥** 页面创建 CCR 客户端 Key,然后从 **服务** 页面启动网关。 这个 npm Compose 脚本会在构建前准备本机的 `../../next-ai/gateway` checkout,让镜像使用支持插件的 ai-gateway runtime。如果直接运行 `docker compose up -d --build`,请先执行 `npm run docker:prepare-gateway`。`npm run docker:build` 也会自动打包这份本地 ai-gateway。 @@ -295,7 +295,7 @@ ports: - **API 密钥** 页面创建的 CCR 客户端 Key 用于模型网关请求。 - 上游供应商凭据是第三类凭据,不应拿来代替 CCR 客户端 Key。 -根路径会重定向到包含 `ccr_web_token` 的管理 URL。请把该 URL 当作密码。远程部署至少应使用固定强 Token、TLS、主机防火墙或私网,并让反向代理把全部路径转发到 Nginx。流式响应和 SSE 不应被代理缓冲。 +Nginx 不会向客户端返回 Token:根路径只会跳转到管理页,并保留请求中已有的 `ccr_web_token` 参数。自动生成的 Token 会在容器日志中打印一次,请把含 Token 的 URL 当作密码。远程部署至少应使用固定强 Token、TLS、主机防火墙或私网,并让反向代理把全部路径转发到 Nginx。流式响应和 SSE 不应被代理缓冲。 外部端口、域名或协议变化时,必须同步设置公开地址: @@ -331,9 +331,9 @@ docker compose logs --tail=200 ccr ### 常见排查 -- `/` 返回 `302`:正常,Nginx 正在跳转到带管理 Token 的页面。 +- `/` 返回 `302`:正常,Nginx 正在跳转到管理页,并保留请求里的查询参数。 - `/health` 返回 `502`:它检查的是模型网关;首次启动尚未配置模型时属于预期行为。 -- 修改 Token 后 UI 返回 `401`:重新打开不带参数的根地址,关闭仍使用旧 Token 的标签页。 +- 修改 Token 后 UI 返回 `401`:用当前 Token 重新打开 `/?ccr_web_token=`,关闭仍使用旧 Token 的标签页。 - 重建后配置消失:检查是否仍挂载同一个 `/data` 卷;`docker compose down --volumes` 会删除数据卷。 - 容器健康但模型请求失败:继续检查服务状态、供应商连通性、CCR 客户端 Key、路由和请求日志;容器健康只表示 Nginx / UI 可访问。 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 2d0f97f5..a8e702aa 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,8 +12,10 @@ CCR_PUBLIC_PORT="${CCR_PUBLIC_PORT:-3458}" CCR_PUBLIC_BASE_URL="${CCR_PUBLIC_BASE_URL:-http://${CCR_PUBLIC_HOST}:${CCR_PUBLIC_PORT}}" CCR_NO_GATEWAY="${CCR_NO_GATEWAY:-0}" +CCR_WEB_AUTH_TOKEN_GENERATED=0 if [ -z "${CCR_WEB_AUTH_TOKEN:-}" ]; then CCR_WEB_AUTH_TOKEN="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")" + CCR_WEB_AUTH_TOKEN_GENERATED=1 fi CCR_WEB_AUTH_TOKEN_QUERY="$(node -e "process.stdout.write(encodeURIComponent(process.argv[1] || ''))" "${CCR_WEB_AUTH_TOKEN}")" @@ -141,14 +143,13 @@ server { client_max_body_size 8m; + # The management token is never put in a response. A token the user supplies + # in the query string is passed on to the page, which reads it from the URL. location = / { - return 302 /pages/home/index.html?ccr_web_token=${CCR_WEB_AUTH_TOKEN_QUERY}; + return 302 /pages/home/index.html\$is_args\$args; } location = /pages/home/index.html { - if (\$arg_ccr_web_token = "") { - return 302 /pages/home/index.html?ccr_web_token=${CCR_WEB_AUTH_TOKEN_QUERY}; - } try_files /pages/home/index.html =404; } @@ -192,6 +193,11 @@ server { } EOF +# A generated token is only reachable through the container logs. +if [ "${CCR_WEB_AUTH_TOKEN_GENERATED}" = "1" ]; then + echo "CCR management UI: ${CCR_PUBLIC_BASE_URL%/}/?ccr_web_token=${CCR_WEB_AUTH_TOKEN_QUERY}" +fi + if [ "$#" -gt 0 ]; then exec "$@" fi diff --git a/docs/src/content/docs/en/guides/docker.md b/docs/src/content/docs/en/guides/docker.md index 9c0b92b7..61f3626e 100644 --- a/docs/src/content/docs/en/guides/docker.md +++ b/docs/src/content/docs/en/guides/docker.md @@ -31,7 +31,7 @@ Nginx exposes: | Path | Purpose | | --- | --- | -| `/`, `/pages/home/index.html` | Management UI. The root path redirects to a page URL containing the management token. | +| `/`, `/pages/home/index.html` | Management UI. The root path redirects to the page and keeps a `ccr_web_token` query parameter if the request has one. Nginx never adds the token. | | `/api/ccr/rpc` | Management RPC, protected by the management token. | | `/health` | Model gateway health; container or UI status is not reflected here. | | `/v1/*`, `/v1beta/*`, `/messages`, `/chat/completions`, `/responses`, `/interactions`, `/mcp/*` | Model and MCP gateway endpoints. | @@ -45,7 +45,7 @@ npm run docker:compose:up docker compose logs -f ccr ``` -Open . On a fresh volume the management UI is available immediately; the model gateway only starts working after a provider and a model have been added. +Open the management URL that the entrypoint prints in the container log, `http://127.0.0.1:3458/?ccr_web_token=`. It is only printed when the token was generated; if you set `CCR_WEB_AUTH_TOKEN` yourself, add it to the URL. On a fresh volume the management UI is available immediately; the model gateway only starts working after a provider and a model have been added. The npm Compose script prepares a local `../../next-ai/gateway` checkout before building so the image uses that plugin-capable ai-gateway runtime. If you run `docker compose up -d --build` directly, run `npm run docker:prepare-gateway` first. `npm run docker:build` performs the same preparation automatically. @@ -104,7 +104,7 @@ The repository also provides `npm run docker:run`. It uses `3458` and `ccr-data` | CCR client API key | Model gateway request authentication | **API Keys** page in the UI | | Upstream provider credential | CCR calling model providers | **Providers** page in the UI | -Without `CCR_WEB_AUTH_TOKEN`, the entrypoint generates a new random token on every container start. Opening the root address still works because Nginx redirects to a URL containing the current token; but persistent and remote deployments should pin a sufficiently long, strong token. +Without `CCR_WEB_AUTH_TOKEN`, the entrypoint generates a new random token on every container start and prints the management URL once to the container log. Nginx never sends the token to a client: opening the root address without a token loads the page, but every management call fails with `401` until the token is supplied in the URL. The page keeps it for the browser tab session. Persistent and remote deployments should pin a sufficiently long, strong token. Do not write the token directly into shell history. Create an environment file that stays out of version control: @@ -258,7 +258,7 @@ docker compose config ### The root address returns `302` -This is expected behavior. Nginx is redirecting the root address to a page URL containing the URL-encoded management token. +This is expected behavior. Nginx redirects the root address to the management page and keeps the query string, so a `ccr_web_token` you supply survives. It does not add a token. ### `/health` returns `502` @@ -266,7 +266,7 @@ It checks the model gateway, not Nginx or the UI. A fresh volume without a confi ### The UI returns `401` after a token change -Reopen the bare root address so Nginx generates a URL containing the new token; close tabs and bookmarks that still use the old `ccr_web_token`. +Reopen `/?ccr_web_token=` with the current token (for a generated token, read it from `docker compose logs ccr`); close tabs and bookmarks that still use the old `ccr_web_token`. ### Clients still use the old port or domain diff --git a/docs/src/content/docs/zh/guides/docker.md b/docs/src/content/docs/zh/guides/docker.md index ea8f806b..8fd490d4 100644 --- a/docs/src/content/docs/zh/guides/docker.md +++ b/docs/src/content/docs/zh/guides/docker.md @@ -31,7 +31,7 @@ Nginx 对外提供: | 路径 | 用途 | | --- | --- | -| `/`、`/pages/home/index.html` | 管理 UI。根路径会跳转到带管理 Token 的页面。 | +| `/`、`/pages/home/index.html` | 管理 UI。根路径会跳转到管理页,并保留请求中已有的 `ccr_web_token` 参数;Nginx 不会自行添加 Token。 | | `/api/ccr/rpc` | 需要管理 Token 的管理 RPC。 | | `/health` | 模型网关健康状态;容器或 UI 状态不在此接口反映。 | | `/v1/*`、`/v1beta/*`、`/messages`、`/chat/completions`、`/responses`、`/interactions`、`/mcp/*` | 模型和 MCP 网关接口。 | @@ -45,7 +45,7 @@ npm run docker:compose:up docker compose logs -f ccr ``` -打开 。新数据卷上管理 UI 会立即可用;模型网关要在添加供应商和模型后才能正常启动。 +打开容器日志中打印的管理地址 `http://127.0.0.1:3458/?ccr_web_token=`(仅在 Token 自动生成时打印;自行设置了 `CCR_WEB_AUTH_TOKEN` 时,请自行拼到地址后)。新数据卷上管理 UI 会立即可用;模型网关要在添加供应商和模型后才能正常启动。 这个 npm Compose 脚本会在构建前准备本机的 `../../next-ai/gateway` checkout,让镜像使用支持插件的 ai-gateway runtime。如果直接运行 `docker compose up -d --build`,请先执行 `npm run docker:prepare-gateway`。`npm run docker:build` 也会自动完成这一步。 @@ -104,7 +104,7 @@ docker run -d \ | CCR 客户端 API Key | 模型网关请求鉴权 | UI 的 **API 密钥** 页面 | | 上游供应商凭据 | CCR 调用模型供应商 | UI 的 **供应商** 页面 | -不设置 `CCR_WEB_AUTH_TOKEN` 时,EntryPoint 每次启动容器都会生成新的随机 Token。打开根地址仍可工作,因为 Nginx 会跳转到包含当前 Token 的 URL;但持久部署和远程部署应固定一个足够长的强 Token。 +不设置 `CCR_WEB_AUTH_TOKEN` 时,EntryPoint 每次启动容器都会生成新的随机 Token,并把管理地址在容器日志中打印一次。Nginx 不会向客户端返回 Token:不带 Token 打开根地址只能加载页面,所有管理请求都会返回 `401`,需要在 URL 中带上 Token。页面会在当前浏览器标签页会话内保留它。持久部署和远程部署应固定一个足够长的强 Token。 不要把 Token 直接写进 Shell 历史。可以创建不进入版本控制的环境文件: @@ -258,7 +258,7 @@ docker compose config ### 根地址返回 `302` -这是正常行为。Nginx 正在把根地址跳转到带 URL 编码管理 Token 的页面。 +这是正常行为。Nginx 把根地址跳转到管理页并保留查询参数,因此你提供的 `ccr_web_token` 不会丢失;Nginx 自身不会添加 Token。 ### `/health` 返回 `502` @@ -266,7 +266,7 @@ docker compose config ### 修改 Token 后 UI 返回 `401` -重新打开不带参数的根地址,让 Nginx 生成包含新 Token 的 URL;关闭仍使用旧 `ccr_web_token` 的标签页和书签。 +用当前 Token 重新打开 `/?ccr_web_token=`(自动生成的 Token 可在 `docker compose logs ccr` 中查看);关闭仍使用旧 `ccr_web_token` 的标签页和书签。 ### 客户端仍使用旧端口或域名 diff --git a/tests/system/docker-smoke.mjs b/tests/system/docker-smoke.mjs index 68447e0a..4d10ac1c 100644 --- a/tests/system/docker-smoke.mjs +++ b/tests/system/docker-smoke.mjs @@ -61,14 +61,20 @@ try { const redirect = await fetch(baseUrl, { redirect: "manual" }); assert.equal(redirect.status, 302); const redirectLocation = redirect.headers.get("location"); - assert.equal(redirectLocation, `/pages/home/index.html?ccr_web_token=${encodeURIComponent(token)}`); + assert.equal(redirectLocation, "/pages/home/index.html", "An unauthenticated request must not be handed the management token"); - const page = await fetch(`${baseUrl}${redirectLocation}`); + const page = await fetch(`${baseUrl}${redirectLocation}`, { redirect: "manual" }); assert.equal(page.status, 200); const pageHtml = await page.text(); assert.match(pageHtml, /Claude Code Router/); assert.match(pageHtml, /web-client-bridge\.js/); + const suppliedToken = `/pages/home/index.html?ccr_web_token=${encodeURIComponent(token)}`; + const tokenRedirect = await fetch(`${baseUrl}/?ccr_web_token=${encodeURIComponent(token)}`, { redirect: "manual" }); + assert.equal(tokenRedirect.status, 302); + assert.equal(tokenRedirect.headers.get("location"), suppliedToken, "A token supplied by the user must survive the root redirect"); + assert.equal((await fetch(`${baseUrl}${suppliedToken}`, { redirect: "manual" })).status, 200); + const bridge = await fetch(`${baseUrl}/assets/web-client-bridge.js`); assert.equal(bridge.status, 200); assert.match(await bridge.text(), /getProviderPresets/);