feat(serve): 可选内置 Web 面板 —— 无头/Docker 部署也能看额度、看日志、切套餐(#58) - #59
Conversation
Adds an opt-in panel for `serve` (Docker/VPS boxes without a TTY), the case raised in TriDefender#58: a loopback-only control listener plus a token-guarded single page to see status and quota, read live logs and switch provider/plan without `docker exec` or hand-editing config.yaml. - Off by default. `ZCODE_PANEL_ENABLED=1` plus a non-empty `ZCODE_PANEL_TOKEN` are required; an empty token is refused and the proxy keeps running as before. - The panel listens on 127.0.0.1 only (`ZCODE_PANEL_PORT`, default 8090) and drives the existing Android control protocol on 127.0.0.1 (`ZCODE_PANEL_CONTROL_PORT`, default 8091). - Every `/api/*` request needs the token. The static shell and `/healthz` carry no account data and stay reachable, because a browser navigation cannot send a custom header. - Reuses `POST /control`, `GET /quota` and the YAML writer unchanged. No new dependencies, no change to the Android path or to `/v1/*`. - A panel that fails to start never blocks the proxy. Tests: `src/server/panel.test.ts` (17 cases), `bun x tsc --noEmit` clean. Verified end-to-end in Docker on a VPS: quota snapshot, incremental logs, stop/start, a real `/v1/chat/completions` call, graceful SIGTERM, and unchanged behaviour when the panel is off. Refs TriDefender#58 Signed-off-by: Ma6302 <143102004+Ma6302@users.noreply.github.com>
|
审查意见与修复示例(基于已审查提交 整体影响:中等。主要影响主动开启 Web 面板的用户,涉及代理控制权限、服务退出、Docker 部署及额度准确性;默认关闭限制了影响范围。以下代码是实现方向示例,需按现有接口调整,尚未应用或验证为完整补丁。 1. [P1] 新增控制监听器缺少鉴权位置: 面板 token 只保护 建议在面板鉴权后直接调用进程内控制分发,取消额外 HTTP 控制端口;或者为内部监听器添加独立鉴权。 // 示意接口:复用进程内控制分发,不额外开放监听端口。
const dispatchControl = createControlDispatcher(hooks);
const panel = await startPanelServer({
...settings,
handleControl: dispatchControl,
});面板处理顺序: if (!isAuthorized(request, settings.token)) {
return new Response("Unauthorized", { status: 401 });
}
// 保留请求大小限制、JSON 校验和原有控制协议响应语义。
return handleControlRequest(request, dispatchControl);如保留内部 HTTP 端口,应使用独立随机凭据,并仅对 serve 的控制监听器强制认证,避免破坏 Android 既有调用协议。 2. [P2] 面板启动失败会遗留控制监听器位置: 控制监听器启动后,如果面板端口被占用, 如果继续保留两个监听器,应在第二步失败时回滚第一步: const control = await startControlListener(controlOptions);
try {
const panel = await startPanelServer(panelOptions);
return { control, panel };
} catch (error) {
await control.close();
throw error;
}
3. [P2] Docker 部署说明无法连接容器回环面板位置: Docker/Compose 示例使用 bridge 网络且只发布 8080,面板却监听容器自己的 127.0.0.1。文档中的 SSH 隧道连接宿主机 127.0.0.1,因此即使将面板环境变量正确传入容器,也无法按说明连接;仅增加 建议提供明确可用的 Docker 接入配置并同步英文说明。例如 Linux VPS 上的 host 网络方式: services:
zcode:
# 保留现有 image、command、volumes 等配置。
network_mode: host
environment:
ZCODE_PANEL_ENABLED: "1"
ZCODE_PANEL_TOKEN: "${ZCODE_PANEL_TOKEN:?请设置面板 token}"
ZCODE_PANEL_PORT: "8090"
# host 网络模式下移除原有 ports 配置。本地建立隧道: ssh -N -L 8090:127.0.0.1:8090 user@host需明确这是 Linux host 网络部署方式,代理主端口也直接使用宿主机网络,应相应核对监听地址和防火墙配置。 4. [P2] 将上游 number 错当成额度总数位置:
建议沿用现有 CLI/TUI 语义,仅展示 remaining,并只在上游提供有效 percentage 时显示比例: remainingElement.textContent =
typeof limit.remaining === "number"
? `${limit.remaining.toLocaleString()} left`
: "Unavailable";
const percentage = limit.percentage;
const hasPercentage =
typeof percentage === "number" &&
Number.isFinite(percentage) &&
percentage >= 0 &&
percentage <= 100;
progressElement.hidden = !hasPercentage;
if (hasPercentage) {
progressElement.value = percentage;
}比例文案及方向应遵循上游定义,不自行假定代表“已用”或“剩余”。 建议验证:无凭据控制请求不能触发 hook;面板启动失败后控制端口被释放;Docker 文档步骤可连通; |
按 @TriDefender 的评审修正四处: - P1:面板不再额外开一个"只靠回环地址保护"的控制监听器。 `src/android/control.ts` 仅新增 `createControlDispatcher(state, ctx)` 导出 (内部 dispatch/Android 路径不变),`serve` 把它交给面板, `/api/control` 的顺序仍是「鉴权 401 → 体积 413 → invalid_json / invalid_command → 分发」,少一个绕过 token 的 HTTP 入口。 - P2:面板启动失败不再遗留可执行命令的监听器(该监听器已不存在); 端口被占时只打一行日志,代理照常服务。 - P2(文档):Docker 说明改为 host 网络 + `network_mode: host` 的 compose 片段,写清 bridge / `-p 8090:8090` 都到不了容器回环,并附 `ssh -N -L 8090:127.0.0.1:8090 user@host` 隧道;README_EN.md 同步。 - P2(额度):面板不再把上游 `number` 当总额度。编码套餐窗口只显示 `<remaining> <unit> remaining`,只有上游给出有效 `percentage`(0–100) 时才按 `1 - pct/100` 画剩余比例条,与 CLI/TUI 的既有语义一致。 验证:`bun x tsc --noEmit` 通过;`bun test src/server/panel.test.ts` 22 pass (含"未带 token 不分发""端口被占不影响已在跑的实例"); 全量 `bun test` 932 pass / 1 fail(唯一失败为既有的 Windows captcha worker 用例)。 Signed-off-by: Ma6302 <143102004+Ma6302@users.noreply.github.com>
|
已按评审全部改完,修复放在第二个提交 f594c5a(分支 1. [P1] 控制监听器缺少鉴权 —— 采纳方案一,取消额外 HTTP 控制口
export function createControlDispatcher(state: ControlState, ctx: HandlerContext): (cmd: ControlCommand) => Promise<ControlResponse> {
return (cmd) => dispatch(cmd, state, ctx);
}
const handleControl = createControlDispatcher(controlState, {
logBuffer, onStartProxy, onStopProxy, onSetConfig, onQuota, onShutdown,
});
const panel = await startPanelServer({ port: settings.port, token: settings.token, handleControl });
服务器复验(阿里云北京,host 网络,镜像基于本 PR 提交构建):
2. [P2] 启动失败遗留控制监听器P1 之后启动路径里已不存在第二个监听器,该问题随之消失。复验:另起一个实例、故意把面板端口设成已被占用的 8092 —— 日志只有一行 3. [P2] Docker 说明连不上容器回环面板
4. [P2] 把上游
|
|
复审基于提交 [P2] 代理停止后仍须执行进程退出位置:src/index.ts:374。 在面板点击 Stop proxy 后, 建议将进程退出及后台任务清理与当前代理句柄是否存在解耦。验证场景:启动面板 → Stop proxy → 分别执行 SIGTERM、SIGINT 和 shutdown,确认后台任务被清理且进程及时退出。 [P2] 将面板登出同步到运行中的认证状态位置:src/server/panel-page.txt:391。 代理运行时点击 Logout 仅派发已有的 logout 命令;该命令删除磁盘凭据,但不停止代理,也不清除 serve 持有的 建议在登出及切换账号时同步更新或失效运行中的认证状态,并协调代理生命周期,避免登出成功后继续消费旧账号额度。验证场景:账号 A 运行代理 → 登出 → 确认新请求不再使用 A;登录 B → 确认状态、额度、代理请求及自动领取所用账号一致。 整体影响评估:中等。主要影响主动启用 Web 面板的 Docker/VPS 用户,涉及服务退出和账号使用;默认关闭面板时未发现请求转发路径受到普遍影响。 |
按 @TriDefender 的复审修正两处 P2: - P2(进程退出):进程退出不再依赖「代理句柄是否存在」。`serve` 把动态 import 的自动领取调度器与验证码池句柄留住,统一由一个幂等的 `shutdown()` 收尾:关面板 → `ClaimScheduler.stop()` → `shutdownCaptcha()` → 若有代理则 `stop(true)`,否则 `process.exit(0)`;SIGINT/SIGTERM 与面板新增的 `shutdown` 命令共用这条路径。面板回 shutdown 时先写响应、50ms 后再退出,避免 `process.exit()` 截断 HTTP 应答。此前从面板 Stop proxy 后 `serverRef.current` 已是 null,信号处理器里的可选调用不再退出,而未 unref 的领取定时器与验证码池定时器仍吊着事件循环,`docker stop` 要等超时强杀、 终端 Ctrl+C 也退不掉。 - P2(认证同步):面板每次成功命令后比对凭据文件指纹,与内存不一致时重新 `setOAuthCredential()` / 新增的 `clearOAuthCredential()`;登出且代理在跑时 先清掉内存凭据再 `stopProxy()`,状态回到 `proxyPort: 0`。此前登出只删磁盘 凭据,`/v1` 与自动领取仍用 AuthManager 里的旧凭据继续消费额度。 `AuthManager` 新增 `clearOAuthCredential()`;Android 控制协议与 `control.ts` 未改动。 验证:`bun x tsc --noEmit` 通过;`bun test` 935 pass / 1 fail(唯一失败为既有 的 Windows captcha worker 用例);面板登出文案改为提示会同时停掉运行中的代理。 VPS 端到端(host 网络,plan=start-plan,自动领取与验证码池都在跑): - 面板 Stop proxy 后 SIGTERM 95ms 退出、SIGINT 114ms 退出,日志 `shutdown: cleared auto-claim + captcha pool timers`; - 面板 `shutdown`(代理仍在跑)先收到 `{"ok":true,"event":"shuttingDown"}`, 297ms 后容器自行退出,exit 0; - 代理运行中删掉磁盘凭据并让面板跑一条命令:下一个 `/v1/chat/completions` 立即返回 503 `credential_unavailable`,证明内存凭据 已同步失效而不是继续用旧账号;凭据放回后再跑一条命令,日志 `auth: switched to the account now on disk`,请求恢复 200; - 面板登出:`{"ok":true,"event":"loggedOut"}`,代理随即停止(8098 不再监听)、 `status` 变成 `proxyPort: 0 / loggedIn: false`、`startProxy` 返回 `not_logged_in`,日志 `panel: logout cleared the live credential — proxy stopped`; - 磁盘凭据删不掉时(EACCES)登出返回 internal_error,代理与内存凭据保持原样, 不会留下半截状态。 Signed-off-by: Ma6302 <143102004+Ma6302@users.noreply.github.com>
|
两条都改了,修复放在第三个提交 a23794b(分支 [P2] 代理停止后仍须执行进程退出 — 已改根因与你描述的一致: 按你的建议把退出与后台清理从代理句柄上解耦:
实测(每个场景单独一个容器,测前
第三条同时说明 50ms 的写出延迟够用;三条都能在日志里看到定时器被显式清掉。 [P2] 将面板登出同步到运行中的认证状态 — 已改
实测(代理运行中,
需要说明一个边界:没有真的用第二个账号做 A→B 换号(手上只有一个可用账号),上面是用"凭据文件删除 / 恢复"驱动同一条指纹路径来验证的。自动领取拿到的是同一个 其他
|
|
lgtm |
这个 PR 做什么
给
serve(Docker / VPS 无 TTY 的场景,也就是 #58 描述的场景)加一个可选的内置 Web 面板:复用已有的 Android 控制协议与/quota,提供状态、额度、实时日志、登入登出、启停代理、切换服务商与套餐。ZCODE_PANEL_ENABLED=1且ZCODE_PANEL_TOKEN非空才启动;token 为空时打印一行提示,代理照常运行。ZCODE_PANEL_PORT(默认 8090)与控制面ZCODE_PANEL_CONTROL_PORT(默认 8091)都只监听127.0.0.1;两个端口配成相同会拒绝启动并提示。/api/*全部要 token(Authorization: Bearer …或X-Panel-Token);静态外壳GET /与GET /healthz免 token —— 浏览器地址栏发不出自定义头,而这两个响应不含任何账号数据。没有沿用/webui的那条鉴权豁免(src/server/server.ts:80的分支排在:87的 proxyApiKey 校验之前);/v1/*与 proxyApiKey 逻辑一行未动。SIGINT/SIGTERM会一并关掉面板与控制面,退出行为与改动前一致。环境变量
ZCODE_PANEL_ENABLED1/true才启用ZCODE_PANEL_TOKEN/api/*ZCODE_PANEL_PORT8090ZCODE_PANEL_CONTROL_PORT8091无头服务器用法:
ZCODE_PANEL_ENABLED=1 ZCODE_PANEL_TOKEN=… docker compose up -d,然后ssh -L 8090:127.0.0.1:8090 user@host,浏览器打开http://127.0.0.1:8090。实现
src/server/panel.ts:resolvePanelSettings()解析并校验环境变量;startPanelServer()起面板;页面路由与POST /api/control。转发是原样的:请求体直接送给127.0.0.1:<controlPort>/control,状态码与响应体原样回传(控制面的 400invalid_json、不可达时的 502 都照传,面板不重写协议)。src/server/panel-page.txt:单页、零外部依赖、<html lang="en">(与webui.txt保持一致,项目页面目前都是英文)。额度是手动刷新(按钮 + 60s 冷却):billing 网关会限流频繁查询,和src/tui/app.ts:249同一条理由,页面里刻意没有额度轮询定时器;日志用getLogs的since游标做增量轮询。src/index.ts的serve():新增installLogTee()(与runAndroid同构的 console tee)与startServePanel(),把startProxy/stopProxy/setConfig/quota/shutdown接上;runAndroid与 Android 打包路径未改。测试
src/server/panel.test.ts:17 个用例 —— 开关真值表、无 token 拒绝启动、未授权请求不触达控制面、两种 token 头都可用、响应原样透传、控制面不可达返回 502、404/405、超 64KB 返回 413、close()释放端口。bun x tsc --noEmit通过;bun test全量 928 pass / 1 fail,唯一失败是 Windows 本机既有的captcha worker dispatch … an unloadable worker entry degrades to in-process solving(改动前基线同样 1 fail)。GET /200、/healthzok、无 token 401、status正确显示proxyPort、quota返回真实快照、getLogs增量游标推进、运行中setConfig→stop_proxy_first、stopProxy/startProxy正常、随后/v1/chat/completions200;docker stop0.15s 优雅退出;不设ZCODE_PANEL_ENABLED时日志与端口与上游完全一致。关联 #58。默认端口、路由风格、页面语言如需调整我再改。