From 0eedcc8d5b5ccbd55a3ef5b7191484f348473c7d Mon Sep 17 00:00:00 2001 From: Ma6302 <143102004+Ma6302@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:50:03 +0800 Subject: [PATCH 1/3] feat(serve): optional web panel for headless deployments Adds an opt-in panel for `serve` (Docker/VPS boxes without a TTY), the case raised in #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 #58 Signed-off-by: Ma6302 <143102004+Ma6302@users.noreply.github.com> --- README.md | 6 + README_EN.md | 6 + src/index.ts | 155 ++++++++++++++- src/server/panel-page.txt | 397 ++++++++++++++++++++++++++++++++++++++ src/server/panel.test.ts | 293 ++++++++++++++++++++++++++++ src/server/panel.ts | 296 ++++++++++++++++++++++++++++ 6 files changed, 1151 insertions(+), 2 deletions(-) create mode 100644 src/server/panel-page.txt create mode 100644 src/server/panel.test.ts create mode 100644 src/server/panel.ts diff --git a/README.md b/README.md index c488a9a..bacb123 100644 --- a/README.md +++ b/README.md @@ -181,9 +181,15 @@ services: | `ZCODE_PROXY_CONFIG` | `config.yaml` | 配置文件路径 | | `ZCODE_PROXY_CREDENTIAL_SECRET` | 机器相关 | 登录凭据的加密种子(跨机器迁移/Docker 时需要固定它) | | `ZCODE_LOG_FORMAT` | 桌面表格 | 设为 `compact` 可得到单行日志(适合窄屏) | +| `ZCODE_PANEL_ENABLED` | 关 | 设为 `1`/`true` 后,无界面的 `serve` 模式(含 Docker)额外启动一个本机 Web 面板 | +| `ZCODE_PANEL_TOKEN` | 无 | 面板的访问令牌,**开启面板时必填**(不填则面板不启动,避免裸奔的控制接口) | +| `ZCODE_PANEL_PORT` | `8090` | 面板端口(只监听 `127.0.0.1`) | +| `ZCODE_PANEL_CONTROL_PORT` | `8091` | 面板背后的回环控制端口(与 Android 面板同一套协议,不能与上一项相同) | 套餐类型(`plan`: `coding-plan` 个人套餐 / `start-plan` 体验套餐)在面板里按 t 切换,会写回 config.yaml。 +服务器 / Docker 这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发(`ssh -L 8090:127.0.0.1:8090 ...`)打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动。 +
diff --git a/README_EN.md b/README_EN.md index ac1da4b..27c963c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -182,9 +182,15 @@ The config file is `config.yaml` in the project root (auto-generated on first st | `ZCODE_PROXY_CONFIG` | `config.yaml` | Config file path | | `ZCODE_PROXY_CREDENTIAL_SECRET` | machine-specific | Encryption seed for login credentials (fix it when migrating across machines / using Docker) | | `ZCODE_LOG_FORMAT` | desktop table | Set to `compact` for single-line logs (good for narrow screens) | +| `ZCODE_PANEL_ENABLED` | off | Set to `1`/`true` to start a local web panel in headless `serve` mode (including Docker) | +| `ZCODE_PANEL_TOKEN` | none | Access token for the panel, **required when the panel is enabled** (without it the panel does not start, so the control endpoints are never left open) | +| `ZCODE_PANEL_PORT` | `8090` | Panel port (bound to `127.0.0.1` only) | +| `ZCODE_PANEL_CONTROL_PORT` | `8091` | Loopback control port behind the panel (same protocol as the Android panel; must differ from the port above) | The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggled in the panel with t, which writes the change back to config.yaml. +Without a TUI (cloud server / Docker) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=`, start the proxy, then forward the port (`ssh -L 8090:127.0.0.1:8090 ...`) and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. +
diff --git a/src/index.ts b/src/index.ts index b604e88..4386a0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,12 @@ import { updateConfigYaml, ensureConfigFile } from "./config/edit.js"; import { openBrowser } from "./runtime/open-browser.js"; import { pasteLoginInstructions, readPastedLine, boldIfTTY } from "./runtime/paste-login.js"; import { buildServerOptions } from "./server/server-options.js"; +import { + resolvePanelSettings, + startPanelServer, + type PanelServer, + type PanelSettings, +} from "./server/panel.js"; import { readFileSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; @@ -166,6 +172,119 @@ Examples: `); } +/** Live panel + its loopback control listener (issue #58). */ +interface PanelRuntime { + panel: PanelServer; + control: Awaited>; +} + +/** + * Mirror console output into a ring buffer so the panel's Logs card has data. + * Same tee the Android entry installs — the buffer is also what `getLogs` reads. + */ +function installLogTee(): LogBuffer { + const buffer = new LogBuffer(); + const origLog = console.log; + const origErr = console.error; + const origWarn = console.warn; + console.log = (...args: unknown[]) => { buffer.push(args.join(" ")); origLog(...args); }; + console.error = (...args: unknown[]) => { buffer.push("[error] " + args.join(" ")); origErr(...args); }; + console.warn = (...args: unknown[]) => { buffer.push("[warn] " + args.join(" ")); origWarn(...args); }; + return buffer; +} + +/** + * Start the optional web panel for `serve`: a loopback control listener (the + * same protocol the Android shell drives) plus the token-guarded panel page. + * `serve` has no TUI, so this is the only way to see quota, read live logs or + * switch provider/plan on a headless box without `docker exec`. + * + * The proxy lifecycle hooks mirror `runAndroid` on purpose: `serve` starts the + * proxy eagerly, so `serverRef` is pre-filled and the start/stop commands only + * matter for restarts (including the `stop_proxy_first` rule before setConfig). + */ +async function startServePanel( + settings: PanelSettings, + ctx: { + config: ProxyConfig; + path: string; + auth: AuthManager; + serverRef: { current: ProxyServer | null }; + logBuffer: LogBuffer; + }, +): Promise { + const { config, path, auth, serverRef, logBuffer } = ctx; + + const controlState: ControlState = { + provider: config.provider, + plan: config.plan, + proxyPort: serverRef.current?.port ?? 0, + }; + + async function startProxy(): Promise<{ ok: true; port: number } | { ok: false; error: string }> { + if (serverRef.current) return { ok: false, error: "already_running" }; + const cred = await loadCredential().catch(() => null); + if (!cred) return { ok: false, error: "not_logged_in" }; + auth.setOAuthCredential(cred); + try { + const s = await startServer(buildServerOptions(config, auth, false)); + serverRef.current = s; + console.log(`zcode-proxy listening on http://${s.hostname}:${s.port}`); + return { ok: true, port: s.port }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + } + + async function stopProxy(): Promise<{ ok: true } | { ok: false; error: string }> { + const s = serverRef.current; + if (!s) return { ok: false, error: "not_running" }; + try { + s.stop(false); + serverRef.current = null; + console.log("zcode-proxy stopped"); + return { ok: true }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + } + + async function setConfig(changes: { + provider?: ProviderId; + plan?: "coding-plan" | "start-plan"; + }): Promise<{ ok: true; provider: ProviderId; plan: "coding-plan" | "start-plan" } | { ok: false; error: string }> { + if (serverRef.current) return { ok: false, error: "stop_proxy_first" }; + if (changes.provider) config.provider = changes.provider; + if (changes.plan) config.plan = changes.plan; + updateConfigYaml(path, { provider: config.provider, plan: config.plan }); + console.log(`config updated: provider=${config.provider} plan=${config.plan}`); + return { ok: true, provider: config.provider, plan: config.plan }; + } + + const control = await startControlListener({ + port: settings.controlPort, + state: controlState, + logBuffer, + onStartProxy: startProxy, + onStopProxy: stopProxy, + onSetConfig: setConfig, + onQuota: () => collectQuotaSnapshot(config), + onShutdown: async () => { + serverRef.current?.stop(true); + }, + }); + console.log(`control listener: 127.0.0.1:${settings.controlPort} (same protocol as Android)`); + + const panel = await startPanelServer({ + port: settings.port, + token: settings.token, + controlPort: settings.controlPort, + }); + console.log(`panel: http://${panel.hostname}:${panel.port} (token required)`); + + return { panel, control }; +} + async function serve(configPath: string | undefined, debug: boolean): Promise { const path = configPath ?? process.env.ZCODE_PROXY_CONFIG ?? "config.yaml"; if (ensureConfigFile(path)) { @@ -175,6 +294,11 @@ async function serve(configPath: string | undefined, debug: boolean): Promise { + if (!panelRuntime) return; + void panelRuntime.control.close().catch(() => {}); + void panelRuntime.panel.close().catch(() => {}); + }; + process.on("SIGINT", () => { console.log("\nShutting down..."); - server.stop(true); + closePanel(); + serverRef.current?.stop(true); }); process.on("SIGTERM", () => { - server.stop(true); + closePanel(); + serverRef.current?.stop(true); }); } diff --git a/src/server/panel-page.txt b/src/server/panel-page.txt new file mode 100644 index 0000000..d79129c --- /dev/null +++ b/src/server/panel-page.txt @@ -0,0 +1,397 @@ + + + + + + + ZCode Proxy · Panel + + + +
+

ZCode Proxy · Panel

+ + not connected + + + +
+ +
+
+

Status

+
+
Provider
—
+
Plan
—
+
Proxy port
—
+
Logged in
—
+
+
+ + + +
+
+ +
+

Quota (manual refresh — the billing gateway rate-limits polling)

+
+ + +
+
No data yet.
+
+ +
+

Config

+
+ + + + + + +
+
Writes back to config.yaml; the proxy must be stopped first.
+
+ +
+

Account

+
+ + + +
+
+
+ +
+

Logs (live, last 500 lines)

+

+        
+ + +
+
+ +
+
+ Last raw request / response +
—
+
+
+
+ + + + diff --git a/src/server/panel.test.ts b/src/server/panel.test.ts new file mode 100644 index 0000000..3975eb4 --- /dev/null +++ b/src/server/panel.test.ts @@ -0,0 +1,293 @@ +/** + * Tests for the optional web panel (issue #58): token enforcement, the + * pass-through to the loopback control listener, and the tokenless static + * routes that a browser navigation cannot attach headers to. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import { createServer, type Server } from "node:http"; +import { + DEFAULT_PANEL_CONTROL_PORT, + DEFAULT_PANEL_PORT, + PANEL_CONTROL_PORT_ENV, + PANEL_ENABLED_ENV, + PANEL_PORT_ENV, + PANEL_TOKEN_ENV, + isPanelEnabled, + resolvePanelSettings, + startPanelServer, + type PanelServer, +} from "./panel.js"; + +const TOKEN = "panel-token-for-tests"; +const CONTROL_OK = JSON.stringify({ ok: true, event: "proxyStopped" }); + +interface StubControl { + port: number; + /** Bodies the panel actually forwarded, in order. */ + bodies: string[]; + /** Canned answer the stub returns for the next request. */ + reply: { status: number; body: string }; + close(): Promise; +} + +/** Minimal stand-in for `startControlListener` (same POST /control contract). */ +async function startStubControl(): Promise { + const stub: StubControl = { + port: 0, + bodies: [], + reply: { status: 200, body: CONTROL_OK }, + close: async () => {}, + }; + const server: Server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + stub.bodies.push(Buffer.concat(chunks).toString("utf8")); + res.writeHead(stub.reply.status, { "content-type": "application/json" }); + res.end(stub.reply.body); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + stub.port = typeof address === "object" && address ? address.port : 0; + stub.close = () => new Promise((resolve) => server.close(() => resolve())); + return stub; +} + +let panels: PanelServer[] = []; +let stubs: StubControl[] = []; + +afterEach(async () => { + await Promise.all(panels.map((panel) => panel.close().catch(() => {}))); + await Promise.all(stubs.map((stub) => stub.close())); + panels = []; + stubs = []; +}); + +/** Panel bound to a free port; the token is always the test token. */ +async function startPanel(controlPort: number): Promise { + const panel = await startPanelServer({ port: 0, token: TOKEN, controlPort }); + panels.push(panel); + return panel; +} + +async function startStub(): Promise { + const stub = await startStubControl(); + stubs.push(stub); + return stub; +} + +function panelUrl(panel: PanelServer, path: string): string { + return `http://127.0.0.1:${panel.port}${path}`; +} + +function controlRequest(panel: PanelServer, init: RequestInit = {}): Promise { + return fetch(panelUrl(panel, "/api/control"), { + method: "POST", + body: JSON.stringify({ cmd: "status" }), + ...init, + }); +} + +describe("isPanelEnabled", () => { + it("stays off unless the flag is explicitly truthy", () => { + expect(isPanelEnabled({})).toBe(false); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "" })).toBe(false); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: " " })).toBe(false); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "0" })).toBe(false); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "false" })).toBe(false); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "FALSE" })).toBe(false); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "no" })).toBe(false); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "off" })).toBe(false); + }); + + it("accepts the usual truthy spellings, case-insensitively", () => { + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "1" })).toBe(true); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "true" })).toBe(true); + expect(isPanelEnabled({ [PANEL_ENABLED_ENV]: "ON" })).toBe(true); + }); +}); + +describe("resolvePanelSettings", () => { + it("returns null while the panel is off, whatever else is set", () => { + expect(resolvePanelSettings({ [PANEL_TOKEN_ENV]: TOKEN })).toBeNull(); + }); + + it("falls back to the default ports", () => { + expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1", [PANEL_TOKEN_ENV]: ` ${TOKEN} ` })).toEqual({ + token: TOKEN, + port: DEFAULT_PANEL_PORT, + controlPort: DEFAULT_PANEL_CONTROL_PORT, + }); + }); + + it("honours explicit ports", () => { + expect( + resolvePanelSettings({ + [PANEL_ENABLED_ENV]: "true", + [PANEL_TOKEN_ENV]: TOKEN, + [PANEL_PORT_ENV]: "9100", + [PANEL_CONTROL_PORT_ENV]: "9101", + }), + ).toEqual({ token: TOKEN, port: 9100, controlPort: 9101 }); + }); + + it("refuses to start without a token rather than serving an open control plane", () => { + expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1" })).toBeNull(); + expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1", [PANEL_TOKEN_ENV]: " " })).toBeNull(); + }); + + it("refuses a port collision with the control listener", () => { + expect( + resolvePanelSettings({ + [PANEL_ENABLED_ENV]: "1", + [PANEL_TOKEN_ENV]: TOKEN, + [PANEL_PORT_ENV]: "8091", + [PANEL_CONTROL_PORT_ENV]: "8091", + }), + ).toBeNull(); + }); +}); + +describe("startPanelServer", () => { + it("refuses to start without a token", async () => { + await expect(startPanelServer({ port: 0, token: "", controlPort: 1 })).rejects.toThrow( + /panel token required/, + ); + await expect(startPanelServer({ port: 0, token: " ", controlPort: 1 })).rejects.toThrow( + /panel token required/, + ); + }); + + it("binds loopback on a free port and reports the real one", async () => { + const panel = await startPanel(1); + expect(panel.hostname).toBe("127.0.0.1"); + expect(panel.port).toBeGreaterThan(0); + }); + + it("frees the port on close", async () => { + const panel = await startPanel(1); + const port = panel.port; + await panel.close(); + panels = panels.filter((candidate) => candidate !== panel); + await expect(fetch(`http://127.0.0.1:${port}/healthz`)).rejects.toThrow(); + }); +}); + +describe("panel static routes", () => { + it("serves the shell and the liveness probe without a token", async () => { + const panel = await startPanel(1); + + for (const path of ["/", "/panel"]) { + const res = await fetch(panelUrl(panel, path)); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain("ZCode Proxy"); + expect(html).toContain("/api/control"); + } + + const health = await fetch(panelUrl(panel, "/healthz")); + expect(health.status).toBe(200); + expect(await health.json()).toEqual({ ok: true, service: "zcode-panel" }); + }); + + it("answers unknown paths with 404 and a non-POST control call with 405", async () => { + const panel = await startPanel(1); + + const missing = await fetch(panelUrl(panel, "/nope")); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ ok: false, error: "not_found: GET /nope" }); + + const wrongMethod = await fetch(panelUrl(panel, "/api/control")); + expect(wrongMethod.status).toBe(405); + expect(await wrongMethod.json()).toEqual({ ok: false, error: "method_not_allowed" }); + }); +}); + +describe("panel control authentication", () => { + it("rejects a missing or wrong token and never talks to the control listener", async () => { + const stub = await startStub(); + const panel = await startPanel(stub.port); + + const attempts: RequestInit[] = [ + {}, + { headers: { authorization: "Bearer wrong" } }, + { headers: { "x-panel-token": "wrong" } }, + { headers: { authorization: TOKEN } }, // missing the "Bearer " prefix + ]; + for (const init of attempts) { + const res = await controlRequest(panel, init); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ ok: false, error: "unauthorized" }); + } + expect(stub.bodies).toEqual([]); + }); + + it("accepts either header spelling and forwards the body verbatim", async () => { + const stub = await startStub(); + const panel = await startPanel(stub.port); + stub.reply = { + status: 200, + body: JSON.stringify({ ok: true, event: "quota", quota: { provider: "zai" } }), + }; + + const viaHeader = await controlRequest(panel, { + headers: { "content-type": "application/json", "x-panel-token": TOKEN }, + body: JSON.stringify({ cmd: "quota" }), + }); + expect(viaHeader.status).toBe(200); + expect(await viaHeader.json()).toEqual({ + ok: true, + event: "quota", + quota: { provider: "zai" }, + }); + + const viaBearer = await controlRequest(panel, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(viaBearer.status).toBe(200); + + expect(stub.bodies).toEqual(['{"cmd":"quota"}', '{"cmd":"status"}']); + }); + + it("passes a control-layer error status and body through unchanged", async () => { + const stub = await startStub(); + const panel = await startPanel(stub.port); + stub.reply = { status: 400, body: JSON.stringify({ ok: false, error: "invalid_json" }) }; + + const res = await controlRequest(panel, { + headers: { authorization: `Bearer ${TOKEN}` }, + body: "not json", + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ ok: false, error: "invalid_json" }); + expect(stub.bodies).toEqual(["not json"]); + }); + + it("reports control_unavailable when the control listener is down", async () => { + const stub = await startStub(); + const controlPort = stub.port; + await stub.close(); + + const panel = await startPanel(controlPort); + const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` } }); + expect(res.status).toBe(502); + const body = (await res.json()) as { ok: boolean; error: string }; + expect(body.ok).toBe(false); + expect(body.error).toContain("control_unavailable"); + }); + + it("rejects an oversized command body", async () => { + const stub = await startStub(); + const panel = await startPanel(stub.port); + + const res = await controlRequest(panel, { + headers: { authorization: `Bearer ${TOKEN}` }, + body: "x".repeat(70 * 1024), + }); + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ ok: false, error: "request_too_large" }); + expect(stub.bodies).toEqual([]); + }); +}); diff --git a/src/server/panel.ts b/src/server/panel.ts new file mode 100644 index 0000000..80fad08 --- /dev/null +++ b/src/server/panel.ts @@ -0,0 +1,296 @@ +/** + * Optional web panel for headless deployments (issue #58). + * + * `serve` has no TUI, and inside Docker there is no terminal to render one + * into — today the only way to see quota, switch provider/plan or read live + * logs is `docker exec` plus hand-editing `config.yaml` and restarting. This + * module exposes the *existing* localhost control protocol + * (`src/android/control.ts`, already used by the Android shell) through a + * token-guarded HTTP surface plus one embedded page. It adds no new state and + * no new upstream calls: + * + * browser → panel (token) → POST /api/control → 127.0.0.1:<controlPort>/control + * + * Security model (deliberate, see the discussion on #58): + * - off by default: `ZCODE_PANEL_ENABLED` must be set to a truthy value; + * - a non-empty `ZCODE_PANEL_TOKEN` is mandatory — no token, no listener; + * - binds loopback only, and never touches `auth.proxyApiKey` or `/v1/*`, + * so enabling the panel does not change the proxy's own auth surface; + * - `/api/*` requires `Authorization: Bearer <token>` or `X-Panel-Token`, + * compared with `timingSafeEqual`; + * - the control listener keeps its own loopback check and its own port, so a + * reachable panel is not a privilege escalation of the `/webui` exemption; + * - `GET /` and `GET /healthz` are tokenless because a browser cannot attach + * a header to a top-level navigation: `/` returns the static shell (no + * account data) and `/healthz` returns a fixed `{"ok":true}`. + */ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { timingSafeEqual } from "node:crypto"; +import panelHtml from "./panel-page.txt" with { type: "text" }; + +/** Env flag that enables the panel. Empty / `0` / `false` / `no` / `off` = off. */ +export const PANEL_ENABLED_ENV = "ZCODE_PANEL_ENABLED"; +/** Shared secret for `/api/*`. Required whenever the panel is enabled. */ +export const PANEL_TOKEN_ENV = "ZCODE_PANEL_TOKEN"; +/** Panel listen port (loopback). */ +export const PANEL_PORT_ENV = "ZCODE_PANEL_PORT"; +/** Loopback port of the control listener the panel forwards to. */ +export const PANEL_CONTROL_PORT_ENV = "ZCODE_PANEL_CONTROL_PORT"; + +/** Defaults mirror the Android entry's wiring so operators only set one thing. */ +export const DEFAULT_PANEL_PORT = 8090; +export const DEFAULT_PANEL_CONTROL_PORT = 8091; + +/** Control commands are small JSON documents; anything bigger is a mistake. */ +const MAX_BODY_BYTES = 64 * 1024; +/** A `quota` command can hit two upstream planes, so allow a slow answer. */ +const CONTROL_TIMEOUT_MS = 30_000; + +export interface PanelOptions { + /** HTTP port. `0` picks a free port (used by tests). */ + port: number; + /** Shared secret required on `/api/*`; must be non-empty. */ + token: string; + /** Loopback port of the control listener to forward commands to. */ + controlPort: number; + /** Bind address. Loopback by default and intentionally not configurable. */ + hostname?: string; +} + +/** Handle for a running panel; `close()` releases the port. */ +export interface PanelServer { + hostname: string; + port: number; + close(): Promise<void>; +} + +/** Fully resolved panel settings; only produced when the panel should run. */ +export interface PanelSettings { + token: string; + port: number; + controlPort: number; +} + +/** Request handler produced by {@link createPanelHandler}. */ +export type PanelHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>; + +/** + * True when `ZCODE_PANEL_ENABLED` asks for the panel. Kept deliberately strict + * and case-insensitive so `ZCODE_PANEL_ENABLED=0`/`false` stay off. + */ +export function isPanelEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = (env[PANEL_ENABLED_ENV] ?? "").trim().toLowerCase(); + return raw !== "" && raw !== "0" && raw !== "false" && raw !== "no" && raw !== "off"; +} + +/** + * Resolve the panel configuration from the environment. Returns `null` when the + * panel must not start — either it was not requested, or it was requested + * without a token / with a port collision, both of which are configuration + * mistakes worth a loud message rather than an unauthenticated listener. + */ +export function resolvePanelSettings(env: NodeJS.ProcessEnv = process.env): PanelSettings | null { + if (!isPanelEnabled(env)) return null; + + const token = (env[PANEL_TOKEN_ENV] ?? "").trim(); + if (!token) { + console.error(`[panel] ${PANEL_ENABLED_ENV} is set but ${PANEL_TOKEN_ENV} is empty — panel not started`); + return null; + } + + const port = Number(env[PANEL_PORT_ENV] ?? DEFAULT_PANEL_PORT) || DEFAULT_PANEL_PORT; + const controlPort = + Number(env[PANEL_CONTROL_PORT_ENV] ?? DEFAULT_PANEL_CONTROL_PORT) || DEFAULT_PANEL_CONTROL_PORT; + if (port === controlPort) { + console.error( + `[panel] ${PANEL_PORT_ENV} and ${PANEL_CONTROL_PORT_ENV} must differ (both ${port}) — panel not started`, + ); + return null; + } + + return { token, port, controlPort }; +} + +/** First value of a possibly-repeated request header. */ +function headerValue(raw: string | string[] | undefined): string | undefined { + if (Array.isArray(raw)) return raw[0]; + return raw; +} + +/** Constant-time token comparison (only the length is revealed). */ +function tokenMatches(provided: string | undefined, expected: string): boolean { + if (!provided) return false; + const a = Buffer.from(provided, "utf8"); + const b = Buffer.from(expected, "utf8"); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** Accept either `Authorization: Bearer <token>` or `X-Panel-Token: <token>`. */ +function extractToken(req: IncomingMessage): string | undefined { + const direct = headerValue(req.headers["x-panel-token"]); + if (direct) return direct.trim(); + const auth = headerValue(req.headers["authorization"]); + if (auth && auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim(); + return undefined; +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload), + "cache-control": "no-store", + }); + res.end(payload); +} + +function sendHtml(res: ServerResponse, html: string): void { + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(html), + "cache-control": "no-store", + }); + res.end(html); +} + +/** + * Read the request body, rejecting anything above {@link MAX_BODY_BYTES}. + * The oversized case keeps draining the socket before answering: replying + * while the client is still writing can reset the connection and lose the 413. + * Returns `null` when the body is too large. + */ +async function readBody(req: IncomingMessage): Promise<string | null> { + const chunks: Buffer[] = []; + let size = 0; + let tooLarge = false; + for await (const chunk of req) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + size += buf.length; + if (size > MAX_BODY_BYTES) { + tooLarge = true; + continue; + } + chunks.push(buf); + } + return tooLarge ? null : Buffer.concat(chunks).toString("utf8"); +} + +/** + * Forward one command to the control listener. Status and body are passed + * through unchanged, so the panel speaks exactly the documented protocol + * (`{ok:true,...}` / `{ok:false,error}`) and never invents a shape. + */ +async function forwardToControl( + controlPort: number, + body: string, +): Promise<{ status: number; body: string }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CONTROL_TIMEOUT_MS); + try { + const res = await fetch(`http://127.0.0.1:${controlPort}/control`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + signal: controller.signal, + }); + return { status: res.status, body: await res.text() }; + } finally { + clearTimeout(timer); + } +} + +/** + * Build the panel request handler. Exported separately from + * {@link startPanelServer} so tests can drive it without binding a port. + */ +export function createPanelHandler(opts: PanelOptions): PanelHandler { + const token = opts.token; + return async (req: IncomingMessage, res: ServerResponse): Promise<void> => { + const method = req.method ?? "GET"; + const url = req.url ?? "/"; + const path = url.split("?")[0] ?? "/"; + + // Static shell + liveness: tokenless by design (see the file header). + if (method === "GET" && (path === "/" || path === "/panel")) { + sendHtml(res, panelHtml); + return; + } + if (method === "GET" && (path === "/healthz" || path === "/api/panel/health")) { + sendJson(res, 200, { ok: true, service: "zcode-panel" }); + return; + } + + if (path !== "/api/control") { + sendJson(res, 404, { ok: false, error: `not_found: ${method} ${path}` }); + return; + } + if (method !== "POST") { + sendJson(res, 405, { ok: false, error: "method_not_allowed" }); + return; + } + if (!tokenMatches(extractToken(req), token)) { + sendJson(res, 401, { ok: false, error: "unauthorized" }); + return; + } + + const body = await readBody(req); + if (body === null) { + sendJson(res, 413, { ok: false, error: "request_too_large" }); + return; + } + + try { + const upstream = await forwardToControl(opts.controlPort, body); + const payload = upstream.body; + res.writeHead(upstream.status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload), + "cache-control": "no-store", + }); + res.end(payload); + } catch (err) { + // The control listener is not up (wrong port, crashed, timed out): say so + // instead of returning a bare 500, because that is the common misconfig. + sendJson(res, 502, { + ok: false, + error: `control_unavailable: ${(err as Error).message}`, + }); + } + }; +} + +/** + * Start the panel on the loopback interface. Throws when the token is missing + * (silently starting an unauthenticated panel is the one outcome this module + * refuses to allow) or when the port cannot be bound. + */ +export async function startPanelServer(opts: PanelOptions): Promise<PanelServer> { + const token = (opts.token ?? "").trim(); + if (!token) throw new Error(`panel token required (set ${PANEL_TOKEN_ENV})`); + const hostname = opts.hostname ?? "127.0.0.1"; + const handler = createPanelHandler({ ...opts, token }); + const server: Server = createServer((req, res) => { + void handler(req, res); + }); + + await new Promise<void>((resolve, reject) => { + const onError = (err: Error): void => reject(err); + server.once("error", onError); + server.listen(opts.port, hostname, () => { + server.removeListener("error", onError); + resolve(); + }); + }); + + const address = server.address(); + const port = typeof address === "object" && address ? address.port : opts.port; + return { + hostname, + port, + close: () => + new Promise<void>((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), + }; +} From f594c5aa686f67ef38a79cbd925c5c149c54f5ba Mon Sep 17 00:00:00 2001 From: Ma6302 <143102004+Ma6302@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:17:18 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(serve):=20=E9=9D=A2=E6=9D=BF=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E6=94=B9=E8=B5=B0=E8=BF=9B=E7=A8=8B=E5=86=85=E5=88=86?= =?UTF-8?q?=E5=8F=91=EF=BC=8C=E5=B9=B6=E6=8C=89=E4=B8=8A=E6=B8=B8=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E6=98=BE=E7=A4=BA=E9=A2=9D=E5=BA=A6=EF=BC=88#59=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 @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> --- README.md | 27 +++- README_EN.md | 27 +++- src/android/control.ts | 17 +++ src/index.ts | 45 ++++--- src/server/panel-page.txt | 24 +++- src/server/panel.test.ts | 277 +++++++++++++++++++++++--------------- src/server/panel.ts | 107 ++++++--------- 7 files changed, 319 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index bacb123..8a2ec81 100644 --- a/README.md +++ b/README.md @@ -184,11 +184,32 @@ services: | `ZCODE_PANEL_ENABLED` | 关 | 设为 `1`/`true` 后,无界面的 `serve` 模式(含 Docker)额外启动一个本机 Web 面板 | | `ZCODE_PANEL_TOKEN` | 无 | 面板的访问令牌,**开启面板时必填**(不填则面板不启动,避免裸奔的控制接口) | | `ZCODE_PANEL_PORT` | `8090` | 面板端口(只监听 `127.0.0.1`) | -| `ZCODE_PANEL_CONTROL_PORT` | `8091` | 面板背后的回环控制端口(与 Android 面板同一套协议,不能与上一项相同) | 套餐类型(`plan`: `coding-plan` 个人套餐 / `start-plan` 体验套餐)在面板里按 <kbd>t</kbd> 切换,会写回 config.yaml。 -服务器 / Docker 这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发(`ssh -L 8090:127.0.0.1:8090 ...`)打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动。 +服务器这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动;命令走进程内分发,不会再额外开一个控制端口。 + +**Docker 里怎么连面板**:面板只监听**容器自己的** `127.0.0.1`,所以默认 bridge 网络下 `-p 8080:8080` 映射不出来,只补一个 `-p 8090:8090` 也连不上(端口映射到的是容器的非回环地址)。Linux 服务器上用 host 网络,让容器直接用宿主机回环: + +```yaml +services: + zcode-proxy: + # 保留现有 image / volumes / restart 等配置 + network_mode: host # host 模式下删掉原来的 ports: + environment: + ZCODE_PROXY_CREDENTIAL_SECRET: "一串只有你知道的口令" + ZCODE_PANEL_ENABLED: "1" + ZCODE_PANEL_TOKEN: "${ZCODE_PANEL_TOKEN:?请先在 .env 里设置面板 token}" + ZCODE_PANEL_PORT: "8090" +``` + +然后在本机建一条只转发的隧道(`-N` 不开 shell): + +```bash +ssh -N -L 8090:127.0.0.1:8090 user@host +``` + +再打开 `http://127.0.0.1:8090`。host 网络下代理主端口也直接占用宿主机端口,安全组/防火墙照旧按原来放行 8080,**不要**对外放行 8090。 </details> @@ -199,7 +220,7 @@ services: **周末/体验套餐自动领取 (claim)** —— 默认开启。代理每 5 分钟探测一次官方的限量套餐活动页,上新瞬间自动帮你抢(`claim.enabled: false` 可关闭)。手动抢:`bun run src/index.ts claim`。 -**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 <kbd>r</kbd> 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的剩余 / 总量与重置时间)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。 +**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 <kbd>r</kbd> 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的**剩余额度**与重置时间——上游 `number` 不是可与剩余比较的总量,所以与 CLI/TUI 一致只显示剩余,只有上游给出百分比时才画比例条)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。 </details> diff --git a/README_EN.md b/README_EN.md index 27c963c..ff88ee3 100644 --- a/README_EN.md +++ b/README_EN.md @@ -185,11 +185,32 @@ The config file is `config.yaml` in the project root (auto-generated on first st | `ZCODE_PANEL_ENABLED` | off | Set to `1`/`true` to start a local web panel in headless `serve` mode (including Docker) | | `ZCODE_PANEL_TOKEN` | none | Access token for the panel, **required when the panel is enabled** (without it the panel does not start, so the control endpoints are never left open) | | `ZCODE_PANEL_PORT` | `8090` | Panel port (bound to `127.0.0.1` only) | -| `ZCODE_PANEL_CONTROL_PORT` | `8091` | Loopback control port behind the panel (same protocol as the Android panel; must differ from the port above) | The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggled in the panel with <kbd>t</kbd>, which writes the change back to config.yaml. -Without a TUI (cloud server / Docker) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=<your own random string>`, start the proxy, then forward the port (`ssh -L 8090:127.0.0.1:8090 ...`) and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. +Without a TUI (cloud server) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=<your own random string>`, start the proxy, then forward the port and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. Commands are dispatched in process, so no extra control port is opened. + +**Reaching the panel from Docker**: the panel listens on the *container's own* `127.0.0.1`, so with the default bridge network `-p 8080:8080` does not expose it, and adding `-p 8090:8090` does not help either (that maps a non-loopback container address). On a Linux server, use host networking so the container shares the host's loopback: + +```yaml +services: + zcode-proxy: + # keep the existing image / volumes / restart settings + network_mode: host # and drop the original ports: block + environment: + ZCODE_PROXY_CREDENTIAL_SECRET: "a-passphrase-only-you-know" + ZCODE_PANEL_ENABLED: "1" + ZCODE_PANEL_TOKEN: "${ZCODE_PANEL_TOKEN:?set a panel token in .env first}" + ZCODE_PANEL_PORT: "8090" +``` + +Then forward-only tunnel from your machine (`-N` = no shell): + +```bash +ssh -N -L 8090:127.0.0.1:8090 user@host +``` + +and open `http://127.0.0.1:8090`. With host networking the proxy port is the host port too, so keep the firewall rules for 8080 as they were and do **not** expose 8090 publicly. </details> @@ -200,7 +221,7 @@ Without a TUI (cloud server / Docker) you can use a browser instead: set `ZCODE_ **Weekend/trial plan auto-claiming (claim)** — enabled by default. The proxy probes the official limited-plan campaign page every 5 minutes and grabs new drops for you the instant they appear (`claim.enabled: false` to disable). Manual run: `bun run src/index.ts claim`. -**Quota display (quota)** — after login the panel fetches quota once automatically; refresh manually with <kbd>r</kbd>. Data comes from two upstream planes: trial/credits-plan buckets (`billing/balance`, remaining / total units, expiry) and individual coding-plan usage windows (`/api/monitor/usage/quota/limit`, same endpoint the official usage panel reads — 5-hour / weekly window remaining / total and reset time). CLI: `bun run src/index.ts quota` (HTTP: `GET /quota`). The upstream gateways rate-limit frequent queries, so the panel does not poll on a timer. +**Quota display (quota)** — after login the panel fetches quota once automatically; refresh manually with <kbd>r</kbd>. Data comes from two upstream planes: trial/credits-plan buckets (`billing/balance`, remaining / total units, expiry) and individual coding-plan usage windows (`/api/monitor/usage/quota/limit`, same endpoint the official usage panel reads — 5-hour / weekly window **remaining** and reset time. Upstream `number` is not a total comparable with remaining, so like the CLI/TUI only remaining is shown, and a bar is drawn only when upstream reports a percentage). CLI: `bun run src/index.ts quota` (HTTP: `GET /quota`). The upstream gateways rate-limit frequent queries, so the panel does not poll on a timer. </details> diff --git a/src/android/control.ts b/src/android/control.ts index d9102dc..07a25bd 100644 --- a/src/android/control.ts +++ b/src/android/control.ts @@ -181,6 +181,23 @@ export interface ControlHandlerResult { body: ControlResponse; } +/** + * Build an in-process dispatcher for the control protocol: identical command + * semantics to `POST /control`, but no listener and no loopback check — the + * caller owns its transport and must guard it (token, origin, size limits). + * + * The Android shell keeps using {@link startControlListener}. Embedders that + * already expose their own authenticated HTTP surface (the `serve` web panel) + * use this instead, so a reachable panel does not also open a second, + * unauthenticated port that can run stopProxy / logout / shutdown. + */ +export function createControlDispatcher( + state: ControlState, + ctx: HandlerContext, +): (cmd: ControlCommand) => Promise<ControlResponse> { + return (cmd) => dispatch(cmd, state, ctx); +} + /** Context passed to `handleControlRequest` for hook wiring + log access. */ export interface HandlerContext { onStartProxy?: () => Promise<LifecycleResult>; diff --git a/src/index.ts b/src/index.ts index 4386a0a..5e9f4c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,12 @@ import { loadConfig } from "./config/loader.js"; import { AuthManager } from "./auth/manager.js"; import { startServer, type ProxyServer } from "./server/server.js"; -import { startControlListener, LogBuffer, type ControlState } from "./android/control.js"; +import { + startControlListener, + createControlDispatcher, + LogBuffer, + type ControlState, +} from "./android/control.js"; import { collectQuotaSnapshot } from "./server/routes-quota.js"; import { loadCredential, saveCredential, clearCredential, getStorePath } from "./auth/store.js"; import { ZaiOAuthClient, BigmodelOAuthClient, BigmodelPollOAuthClient, LOGIN_TIMEOUT_MS, parsePastedCallbackUrl, type OAuthResult } from "./auth/oauth.js"; @@ -172,12 +177,6 @@ Examples: `); } -/** Live panel + its loopback control listener (issue #58). */ -interface PanelRuntime { - panel: PanelServer; - control: Awaited<ReturnType<typeof startControlListener>>; -} - /** * Mirror console output into a ring buffer so the panel's Logs card has data. * Same tee the Android entry installs — the buffer is also what `getLogs` reads. @@ -194,10 +193,14 @@ function installLogTee(): LogBuffer { } /** - * Start the optional web panel for `serve`: a loopback control listener (the - * same protocol the Android shell drives) plus the token-guarded panel page. - * `serve` has no TUI, so this is the only way to see quota, read live logs or - * switch provider/plan on a headless box without `docker exec`. + * Start the optional web panel for `serve`. `serve` has no TUI, so this is the + * only way to see quota, read live logs or switch provider/plan on a headless + * box without `docker exec`. + * + * Commands are dispatched in-process through `createControlDispatcher()` — the + * same protocol the Android shell drives over `POST /control`, without opening a + * second, unauthenticated loopback port. The panel token is therefore the only + * way in. * * The proxy lifecycle hooks mirror `runAndroid` on purpose: `serve` starts the * proxy eagerly, so `serverRef` is pre-filled and the start/stop commands only @@ -212,7 +215,7 @@ async function startServePanel( serverRef: { current: ProxyServer | null }; logBuffer: LogBuffer; }, -): Promise<PanelRuntime> { +): Promise<PanelServer> { const { config, path, auth, serverRef, logBuffer } = ctx; const controlState: ControlState = { @@ -261,9 +264,11 @@ async function startServePanel( return { ok: true, provider: config.provider, plan: config.plan }; } - const control = await startControlListener({ - port: settings.controlPort, - state: controlState, + // Dispatched in-process: the panel already guards its own transport with a + // token, so a second loopback listener would only add an unauthenticated way + // to reach stopProxy / logout / shutdown (issue #58 review, P1) and a second + // thing to clean up when the panel fails to start (P2, now structurally gone). + const handleControl = createControlDispatcher(controlState, { logBuffer, onStartProxy: startProxy, onStopProxy: stopProxy, @@ -273,16 +278,15 @@ async function startServePanel( serverRef.current?.stop(true); }, }); - console.log(`control listener: 127.0.0.1:${settings.controlPort} (same protocol as Android)`); const panel = await startPanelServer({ port: settings.port, token: settings.token, - controlPort: settings.controlPort, + handleControl, }); console.log(`panel: http://${panel.hostname}:${panel.port} (token required)`); - return { panel, control }; + return panel; } async function serve(configPath: string | undefined, debug: boolean): Promise<void> { @@ -339,7 +343,7 @@ async function serve(configPath: string | undefined, debug: boolean): Promise<vo } if (debug) console.log(` debug: ON`); - let panelRuntime: PanelRuntime | null = null; + let panelRuntime: PanelServer | null = null; if (panelSettings && panelLogBuffer) { try { panelRuntime = await startServePanel(panelSettings, { @@ -357,8 +361,7 @@ async function serve(configPath: string | undefined, debug: boolean): Promise<vo const closePanel = (): void => { if (!panelRuntime) return; - void panelRuntime.control.close().catch(() => {}); - void panelRuntime.panel.close().catch(() => {}); + void panelRuntime.close().catch(() => {}); }; process.on("SIGINT", () => { diff --git a/src/server/panel-page.txt b/src/server/panel-page.txt index d79129c..0ce1c56 100644 --- a/src/server/panel-page.txt +++ b/src/server/panel-page.txt @@ -278,13 +278,27 @@ if (plan) { parts.push('<div class="row"><strong>' + esc(plan.level || "coding plan") + "</strong></div>"); for (const limit of plan.limits || []) { - const used = limit.percentage != null ? limit.percentage : (limit.total ? (limit.used / limit.total) * 100 : null); + // Upstream `number` is NOT a comparable total (live TIME_LIMIT row: + // remaining=3894, number=1), so like the CLI/TUI this shows remaining + // alone. `percentage` is the one self-consistent signal: when present + // it is a used share (0-100), and the bar is the remaining share + // derived from it — never an X/Y fabrication. Direction is not + // re-labelled here because upstream owns its meaning. + const remaining = limit.remaining; + const pct = limit.percentage; + const remainingFrac = + typeof pct === "number" && isFinite(pct) && pct >= 0 && pct <= 100 ? 1 - pct / 100 : null; parts.push( '<div class="row"><span class="muted">' + esc(limit.type) + "</span> <strong>" + - esc(limit.remaining ?? "?") + "</strong> left of " + esc(limit.total ?? "?") + " " + esc(limit.unit || "") + - (limit.nextResetTime ? " · resets " + esc(fmtTs(limit.nextResetTime)) : "") + - "</span></div>" + - (used == null ? "" : '<div class="bar"><i style="width:' + Math.min(100, Math.max(0, used)).toFixed(1) + '%"></i></div>'), + (typeof remaining === "number" + ? esc(remaining.toLocaleString()) + (limit.unit ? " " + esc(limit.unit) : "") + " remaining" + : "unavailable") + + "</strong>" + + (limit.nextResetTime ? ' <span class="muted">· resets ' + esc(fmtTs(limit.nextResetTime)) + "</span>" : "") + + "</div>" + + (remainingFrac === null + ? "" + : '<div class="bar"><i style="width:' + (remainingFrac * 100).toFixed(1) + '%"></i></div>'), ); } } else { diff --git a/src/server/panel.test.ts b/src/server/panel.test.ts index 3975eb4..dc87074 100644 --- a/src/server/panel.test.ts +++ b/src/server/panel.test.ts @@ -1,14 +1,12 @@ /** * Tests for the optional web panel (issue #58): token enforcement, the - * pass-through to the loopback control listener, and the tokenless static - * routes that a browser navigation cannot attach headers to. + * in-process control dispatch behind `POST /api/control` (no second listener), + * and the tokenless static routes a browser navigation cannot attach a header + * to. */ import { afterEach, describe, expect, it } from "bun:test"; -import { createServer, type Server } from "node:http"; import { - DEFAULT_PANEL_CONTROL_PORT, DEFAULT_PANEL_PORT, - PANEL_CONTROL_PORT_ENV, PANEL_ENABLED_ENV, PANEL_PORT_ENV, PANEL_TOKEN_ENV, @@ -17,66 +15,35 @@ import { startPanelServer, type PanelServer, } from "./panel.js"; +import { + LogBuffer, + createControlDispatcher, + type ControlCommand, + type ControlResponse, + type ControlState, +} from "../android/control.js"; const TOKEN = "panel-token-for-tests"; -const CONTROL_OK = JSON.stringify({ ok: true, event: "proxyStopped" }); - -interface StubControl { - port: number; - /** Bodies the panel actually forwarded, in order. */ - bodies: string[]; - /** Canned answer the stub returns for the next request. */ - reply: { status: number; body: string }; - close(): Promise<void>; -} -/** Minimal stand-in for `startControlListener` (same POST /control contract). */ -async function startStubControl(): Promise<StubControl> { - const stub: StubControl = { - port: 0, - bodies: [], - reply: { status: 200, body: CONTROL_OK }, - close: async () => {}, - }; - const server: Server = createServer((req, res) => { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", () => { - stub.bodies.push(Buffer.concat(chunks).toString("utf8")); - res.writeHead(stub.reply.status, { "content-type": "application/json" }); - res.end(stub.reply.body); - }); - }); - await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve())); - const address = server.address(); - stub.port = typeof address === "object" && address ? address.port : 0; - stub.close = () => new Promise<void>((resolve) => server.close(() => resolve())); - return stub; -} +/** Same shape the serve entry passes to `startPanelServer`. */ +type Dispatcher = (cmd: ControlCommand) => Promise<ControlResponse>; let panels: PanelServer[] = []; -let stubs: StubControl[] = []; afterEach(async () => { await Promise.all(panels.map((panel) => panel.close().catch(() => {}))); - await Promise.all(stubs.map((stub) => stub.close())); panels = []; - stubs = []; }); /** Panel bound to a free port; the token is always the test token. */ -async function startPanel(controlPort: number): Promise<PanelServer> { - const panel = await startPanelServer({ port: 0, token: TOKEN, controlPort }); +async function startPanel( + handleControl: Dispatcher = async () => ({ ok: true, event: "proxyStopped" }), +): Promise<PanelServer> { + const panel = await startPanelServer({ port: 0, token: TOKEN, handleControl }); panels.push(panel); return panel; } -async function startStub(): Promise<StubControl> { - const stub = await startStubControl(); - stubs.push(stub); - return stub; -} - function panelUrl(panel: PanelServer, path: string): string { return `http://127.0.0.1:${panel.port}${path}`; } @@ -113,70 +80,71 @@ describe("resolvePanelSettings", () => { expect(resolvePanelSettings({ [PANEL_TOKEN_ENV]: TOKEN })).toBeNull(); }); - it("falls back to the default ports", () => { + it("resolves the token, the default port, and nothing else", () => { + // Only `{token, port}`: the panel no longer has a control port to forward + // to, so there is no second listener that could outlive a failed start. expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1", [PANEL_TOKEN_ENV]: ` ${TOKEN} ` })).toEqual({ token: TOKEN, port: DEFAULT_PANEL_PORT, - controlPort: DEFAULT_PANEL_CONTROL_PORT, }); }); - it("honours explicit ports", () => { + it("honours an explicit panel port", () => { expect( resolvePanelSettings({ [PANEL_ENABLED_ENV]: "true", [PANEL_TOKEN_ENV]: TOKEN, [PANEL_PORT_ENV]: "9100", - [PANEL_CONTROL_PORT_ENV]: "9101", }), - ).toEqual({ token: TOKEN, port: 9100, controlPort: 9101 }); + ).toEqual({ token: TOKEN, port: 9100 }); }); it("refuses to start without a token rather than serving an open control plane", () => { expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1" })).toBeNull(); expect(resolvePanelSettings({ [PANEL_ENABLED_ENV]: "1", [PANEL_TOKEN_ENV]: " " })).toBeNull(); }); - - it("refuses a port collision with the control listener", () => { - expect( - resolvePanelSettings({ - [PANEL_ENABLED_ENV]: "1", - [PANEL_TOKEN_ENV]: TOKEN, - [PANEL_PORT_ENV]: "8091", - [PANEL_CONTROL_PORT_ENV]: "8091", - }), - ).toBeNull(); - }); }); describe("startPanelServer", () => { it("refuses to start without a token", async () => { - await expect(startPanelServer({ port: 0, token: "", controlPort: 1 })).rejects.toThrow( + const handleControl: Dispatcher = async () => ({ ok: true, event: "proxyStopped" }); + await expect(startPanelServer({ port: 0, token: "", handleControl })).rejects.toThrow( /panel token required/, ); - await expect(startPanelServer({ port: 0, token: " ", controlPort: 1 })).rejects.toThrow( + await expect(startPanelServer({ port: 0, token: " ", handleControl })).rejects.toThrow( /panel token required/, ); }); it("binds loopback on a free port and reports the real one", async () => { - const panel = await startPanel(1); + const panel = await startPanel(); expect(panel.hostname).toBe("127.0.0.1"); expect(panel.port).toBeGreaterThan(0); }); it("frees the port on close", async () => { - const panel = await startPanel(1); + const panel = await startPanel(); const port = panel.port; await panel.close(); panels = panels.filter((candidate) => candidate !== panel); await expect(fetch(`http://127.0.0.1:${port}/healthz`)).rejects.toThrow(); }); + + it("fails on a taken port without disturbing the panel already there", async () => { + // Regression for the #58 review's P2: with no control listener in the + // startup path there is nothing partially started to leak or to clean up. + const first = await startPanel(); + const handleControl: Dispatcher = async () => ({ ok: true, event: "proxyStopped" }); + await expect(startPanelServer({ port: first.port, token: TOKEN, handleControl })).rejects.toThrow(); + + const health = await fetch(panelUrl(first, "/healthz")); + expect(health.status).toBe(200); + }); }); describe("panel static routes", () => { it("serves the shell and the liveness probe without a token", async () => { - const panel = await startPanel(1); + const panel = await startPanel(); for (const path of ["/", "/panel"]) { const res = await fetch(panelUrl(panel, path)); @@ -192,8 +160,21 @@ describe("panel static routes", () => { expect(await health.json()).toEqual({ ok: true, service: "zcode-panel" }); }); + it("never fabricates a total for a coding-plan limit", async () => { + // Regression for the #58 review's P2/P4: upstream `number` is not a + // comparable total (live TIME_LIMIT row: remaining=3894, number=1), so the + // window row shows `remaining` alone and only draws a bar when upstream + // hands us a usable percentage. + const panel = await startPanel(); + const html = await (await fetch(panelUrl(panel, "/"))).text(); + expect(html).not.toContain("left of"); + expect(html).not.toContain("limit.total"); + expect(html).toContain("remaining"); + expect(html).toContain("limit.percentage"); + }); + it("answers unknown paths with 404 and a non-POST control call with 405", async () => { - const panel = await startPanel(1); + const panel = await startPanel(); const missing = await fetch(panelUrl(panel, "/nope")); expect(missing.status).toBe(404); @@ -206,9 +187,12 @@ describe("panel static routes", () => { }); describe("panel control authentication", () => { - it("rejects a missing or wrong token and never talks to the control listener", async () => { - const stub = await startStub(); - const panel = await startPanel(stub.port); + it("rejects a missing or wrong token without dispatching anything", async () => { + let calls = 0; + const panel = await startPanel(async () => { + calls++; + return { ok: true, event: "proxyStopped" }; + }); const attempts: RequestInit[] = [ {}, @@ -221,66 +205,83 @@ describe("panel control authentication", () => { expect(res.status).toBe(401); expect(await res.json()).toEqual({ ok: false, error: "unauthorized" }); } - expect(stub.bodies).toEqual([]); + expect(calls).toBe(0); }); - it("accepts either header spelling and forwards the body verbatim", async () => { - const stub = await startStub(); - const panel = await startPanel(stub.port); - stub.reply = { - status: 200, - body: JSON.stringify({ ok: true, event: "quota", quota: { provider: "zai" } }), + it("rejects an unauthenticated oversized body as 401, not 413", async () => { + let calls = 0; + const panel = await startPanel(async () => { + calls++; + return { ok: true, event: "proxyStopped" }; + }); + + const res = await controlRequest(panel, { body: "x".repeat(70 * 1024) }); + expect(res.status).toBe(401); + expect(calls).toBe(0); + }); + + it("accepts either header spelling and returns the control envelope", async () => { + const seen: ControlCommand[] = []; + const handleControl: Dispatcher = async (cmd) => { + seen.push(cmd); + if (cmd.cmd !== "quota") return { ok: true, event: "loggedOut" }; + return { ok: true, event: "quota", quota: { provider: "zai" } as never }; }; + const panel = await startPanel(handleControl); const viaHeader = await controlRequest(panel, { headers: { "content-type": "application/json", "x-panel-token": TOKEN }, body: JSON.stringify({ cmd: "quota" }), }); expect(viaHeader.status).toBe(200); - expect(await viaHeader.json()).toEqual({ - ok: true, - event: "quota", - quota: { provider: "zai" }, - }); + expect(await viaHeader.json()).toEqual({ ok: true, event: "quota", quota: { provider: "zai" } }); const viaBearer = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` }, }); expect(viaBearer.status).toBe(200); - expect(stub.bodies).toEqual(['{"cmd":"quota"}', '{"cmd":"status"}']); + expect(seen).toEqual([{ cmd: "quota" }, { cmd: "status" }]); }); - it("passes a control-layer error status and body through unchanged", async () => { - const stub = await startStub(); - const panel = await startPanel(stub.port); - stub.reply = { status: 400, body: JSON.stringify({ ok: false, error: "invalid_json" }) }; - + it("keeps the control protocol's error envelope (200 + ok:false) verbatim", async () => { + const panel = await startPanel(async (cmd) => ({ ok: false, error: `unknown_cmd: ${cmd.cmd}` })); const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` }, - body: "not json", + body: JSON.stringify({ cmd: "nope" }), }); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ ok: false, error: "invalid_json" }); - expect(stub.bodies).toEqual(["not json"]); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: false, error: "unknown_cmd: nope" }); }); - it("reports control_unavailable when the control listener is down", async () => { - const stub = await startStub(); - const controlPort = stub.port; - await stub.close(); + it("rejects malformed JSON and non-command payloads before dispatch", async () => { + let calls = 0; + const panel = await startPanel(async () => { + calls++; + return { ok: true, event: "proxyStopped" }; + }); - const panel = await startPanel(controlPort); - const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` } }); - expect(res.status).toBe(502); - const body = (await res.json()) as { ok: boolean; error: string }; - expect(body.ok).toBe(false); - expect(body.error).toContain("control_unavailable"); + const badJson = await controlRequest(panel, { + headers: { authorization: `Bearer ${TOKEN}` }, + body: "not json", + }); + expect(badJson.status).toBe(400); + expect(await badJson.json()).toEqual({ ok: false, error: "invalid_json" }); + + for (const body of ["null", "[]", '"status"', "{}", '{"cmd":42}']) { + const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` }, body }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ ok: false, error: "invalid_command" }); + } + expect(calls).toBe(0); }); - it("rejects an oversized command body", async () => { - const stub = await startStub(); - const panel = await startPanel(stub.port); + it("rejects an oversized command body before dispatch", async () => { + let calls = 0; + const panel = await startPanel(async () => { + calls++; + return { ok: true, event: "proxyStopped" }; + }); const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` }, @@ -288,6 +289,62 @@ describe("panel control authentication", () => { }); expect(res.status).toBe(413); expect(await res.json()).toEqual({ ok: false, error: "request_too_large" }); - expect(stub.bodies).toEqual([]); + expect(calls).toBe(0); + }); + + it("reports a dispatcher crash as internal_error instead of a bare failure", async () => { + const panel = await startPanel(async () => { + throw new Error("boom"); + }); + const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` } }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ ok: false, error: "internal_error: boom" }); + }); +}); + +describe("panel ↔ control dispatcher wiring", () => { + it("runs a real control command in process, with no extra listener", async () => { + // The full path the panel uses in `serve`: a real dispatcher built from the + // Android control module, driven over the panel's own authenticated HTTP + // surface. Nothing here binds a control port. + let stops = 0; + const state: ControlState = { provider: "zai", plan: "coding-plan", proxyPort: 8080 }; + const handleControl = createControlDispatcher(state, { + logBuffer: new LogBuffer(), + onStopProxy: async () => { + stops++; + return { ok: true }; + }, + }); + const panel = await startPanel(handleControl); + + const denied = await controlRequest(panel, { + body: JSON.stringify({ cmd: "stopProxy" }), + }); + expect(denied.status).toBe(401); + expect(stops).toBe(0); + + const allowed = await controlRequest(panel, { + headers: { authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ cmd: "stopProxy" }), + }); + expect(allowed.status).toBe(200); + expect(await allowed.json()).toEqual({ ok: true, event: "proxyStopped" }); + expect(stops).toBe(1); + expect(state.proxyPort).toBe(0); + }); + + it("answers `status` from the same hook state the proxy entry uses", async () => { + const state: ControlState = { provider: "bigmodel", plan: "start-plan", proxyPort: 0 }; + const handleControl = createControlDispatcher(state, { logBuffer: new LogBuffer() }); + const panel = await startPanel(handleControl); + + const res = await controlRequest(panel, { headers: { authorization: `Bearer ${TOKEN}` } }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; provider: string; plan: string; proxyPort: number }; + expect(body.ok).toBe(true); + expect(body.provider).toBe("bigmodel"); + expect(body.plan).toBe("start-plan"); + expect(body.proxyPort).toBe(0); }); }); diff --git a/src/server/panel.ts b/src/server/panel.ts index 80fad08..96e038d 100644 --- a/src/server/panel.ts +++ b/src/server/panel.ts @@ -9,7 +9,14 @@ * token-guarded HTTP surface plus one embedded page. It adds no new state and * no new upstream calls: * - * browser → panel (token) → POST /api/control → 127.0.0.1:<controlPort>/control + * browser → panel (token) → POST /api/control → in-process dispatcher + * + * The dispatcher is `createControlDispatcher()` from `src/android/control.ts`: + * the same command semantics `POST /control` serves, called directly instead of + * over a second loopback HTTP port. Opening such a port would mean an + * unauthenticated path to `stopProxy` / `logout` / `shutdown` for anything that + * can reach loopback (a browser on the box can POST `text/plain` cross-origin + * without reading the response), so the panel does not do it. * * Security model (deliberate, see the discussion on #58): * - off by default: `ZCODE_PANEL_ENABLED` must be set to a truthy value; @@ -17,15 +24,15 @@ * - binds loopback only, and never touches `auth.proxyApiKey` or `/v1/*`, * so enabling the panel does not change the proxy's own auth surface; * - `/api/*` requires `Authorization: Bearer <token>` or `X-Panel-Token`, - * compared with `timingSafeEqual`; - * - the control listener keeps its own loopback check and its own port, so a - * reachable panel is not a privilege escalation of the `/webui` exemption; + * compared with `timingSafeEqual`, and a body above `MAX_BODY_BYTES` is + * rejected before it is parsed; * - `GET /` and `GET /healthz` are tokenless because a browser cannot attach * a header to a top-level navigation: `/` returns the static shell (no * account data) and `/healthz` returns a fixed `{"ok":true}`. */ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { timingSafeEqual } from "node:crypto"; +import type { ControlCommand, ControlResponse } from "../android/control.js"; import panelHtml from "./panel-page.txt" with { type: "text" }; /** Env flag that enables the panel. Empty / `0` / `false` / `no` / `off` = off. */ @@ -34,25 +41,26 @@ export const PANEL_ENABLED_ENV = "ZCODE_PANEL_ENABLED"; export const PANEL_TOKEN_ENV = "ZCODE_PANEL_TOKEN"; /** Panel listen port (loopback). */ export const PANEL_PORT_ENV = "ZCODE_PANEL_PORT"; -/** Loopback port of the control listener the panel forwards to. */ -export const PANEL_CONTROL_PORT_ENV = "ZCODE_PANEL_CONTROL_PORT"; /** Defaults mirror the Android entry's wiring so operators only set one thing. */ export const DEFAULT_PANEL_PORT = 8090; -export const DEFAULT_PANEL_CONTROL_PORT = 8091; /** Control commands are small JSON documents; anything bigger is a mistake. */ const MAX_BODY_BYTES = 64 * 1024; -/** A `quota` command can hit two upstream planes, so allow a slow answer. */ -const CONTROL_TIMEOUT_MS = 30_000; + +/** + * In-process control dispatch: `POST /api/control` hands the parsed command to + * this and returns whatever the control protocol answers, unchanged. + */ +export type ControlDispatcher = (cmd: ControlCommand) => Promise<ControlResponse>; export interface PanelOptions { /** HTTP port. `0` picks a free port (used by tests). */ port: number; /** Shared secret required on `/api/*`; must be non-empty. */ token: string; - /** Loopback port of the control listener to forward commands to. */ - controlPort: number; + /** In-process dispatcher backing `POST /api/control`. */ + handleControl: ControlDispatcher; /** Bind address. Loopback by default and intentionally not configurable. */ hostname?: string; } @@ -68,7 +76,6 @@ export interface PanelServer { export interface PanelSettings { token: string; port: number; - controlPort: number; } /** Request handler produced by {@link createPanelHandler}. */ @@ -86,8 +93,8 @@ export function isPanelEnabled(env: NodeJS.ProcessEnv = process.env): boolean { /** * Resolve the panel configuration from the environment. Returns `null` when the * panel must not start — either it was not requested, or it was requested - * without a token / with a port collision, both of which are configuration - * mistakes worth a loud message rather than an unauthenticated listener. + * without a token, which is a configuration mistake worth a loud message rather + * than an unauthenticated listener. */ export function resolvePanelSettings(env: NodeJS.ProcessEnv = process.env): PanelSettings | null { if (!isPanelEnabled(env)) return null; @@ -99,16 +106,7 @@ export function resolvePanelSettings(env: NodeJS.ProcessEnv = process.env): Pane } const port = Number(env[PANEL_PORT_ENV] ?? DEFAULT_PANEL_PORT) || DEFAULT_PANEL_PORT; - const controlPort = - Number(env[PANEL_CONTROL_PORT_ENV] ?? DEFAULT_PANEL_CONTROL_PORT) || DEFAULT_PANEL_CONTROL_PORT; - if (port === controlPort) { - console.error( - `[panel] ${PANEL_PORT_ENV} and ${PANEL_CONTROL_PORT_ENV} must differ (both ${port}) — panel not started`, - ); - return null; - } - - return { token, port, controlPort }; + return { token, port }; } /** First value of a possibly-repeated request header. */ @@ -176,30 +174,6 @@ async function readBody(req: IncomingMessage): Promise<string | null> { return tooLarge ? null : Buffer.concat(chunks).toString("utf8"); } -/** - * Forward one command to the control listener. Status and body are passed - * through unchanged, so the panel speaks exactly the documented protocol - * (`{ok:true,...}` / `{ok:false,error}`) and never invents a shape. - */ -async function forwardToControl( - controlPort: number, - body: string, -): Promise<{ status: number; body: string }> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), CONTROL_TIMEOUT_MS); - try { - const res = await fetch(`http://127.0.0.1:${controlPort}/control`, { - method: "POST", - headers: { "content-type": "application/json" }, - body, - signal: controller.signal, - }); - return { status: res.status, body: await res.text() }; - } finally { - clearTimeout(timer); - } -} - /** * Build the panel request handler. Exported separately from * {@link startPanelServer} so tests can drive it without binding a port. @@ -229,6 +203,8 @@ export function createPanelHandler(opts: PanelOptions): PanelHandler { sendJson(res, 405, { ok: false, error: "method_not_allowed" }); return; } + // Auth before anything else: an unauthenticated request must not reach the + // dispatcher (which can stop the proxy or clear the stored credential). if (!tokenMatches(extractToken(req), token)) { sendJson(res, 401, { ok: false, error: "unauthorized" }); return; @@ -240,22 +216,26 @@ export function createPanelHandler(opts: PanelOptions): PanelHandler { return; } + let cmd: ControlCommand; + try { + const parsed: unknown = JSON.parse(body); + if (typeof parsed !== "object" || parsed === null || typeof (parsed as { cmd?: unknown }).cmd !== "string") { + sendJson(res, 400, { ok: false, error: "invalid_command" }); + return; + } + cmd = parsed as ControlCommand; + } catch { + sendJson(res, 400, { ok: false, error: "invalid_json" }); + return; + } + try { - const upstream = await forwardToControl(opts.controlPort, body); - const payload = upstream.body; - res.writeHead(upstream.status, { - "content-type": "application/json; charset=utf-8", - "content-length": Buffer.byteLength(payload), - "cache-control": "no-store", - }); - res.end(payload); + // Same envelope semantics as the control listener: a failed command is a + // 200 with `{ok:false,error}`, so the page can render the reason verbatim. + const result = await opts.handleControl(cmd); + sendJson(res, 200, result); } catch (err) { - // The control listener is not up (wrong port, crashed, timed out): say so - // instead of returning a bare 500, because that is the common misconfig. - sendJson(res, 502, { - ok: false, - error: `control_unavailable: ${(err as Error).message}`, - }); + sendJson(res, 500, { ok: false, error: `internal_error: ${(err as Error).message}` }); } }; } @@ -263,7 +243,8 @@ export function createPanelHandler(opts: PanelOptions): PanelHandler { /** * Start the panel on the loopback interface. Throws when the token is missing * (silently starting an unauthenticated panel is the one outcome this module - * refuses to allow) or when the port cannot be bound. + * refuses to allow) or when the port cannot be bound. There is nothing else to + * roll back on failure: the panel owns the only listener it opens. */ export async function startPanelServer(opts: PanelOptions): Promise<PanelServer> { const token = (opts.token ?? "").trim(); From a23794be13a6c12992faec94b21fa31240979d76 Mon Sep 17 00:00:00 2001 From: Ma6302 <143102004+Ma6302@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:10:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(serve):=20=E9=9D=A2=E6=9D=BF=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E4=BB=A3=E7=90=86=E5=90=8E=E4=BB=8D=E8=83=BD=E6=AD=A3?= =?UTF-8?q?=E5=B8=B8=E9=80=80=E5=87=BA=EF=BC=8C=E7=99=BB=E5=87=BA=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E8=BF=90=E8=A1=8C=E4=B8=AD=E7=9A=84=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=EF=BC=88#59=20=E8=AF=84=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 @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> --- README.md | 2 +- README_EN.md | 2 +- src/auth/manager.test.ts | 15 ++++ src/auth/manager.ts | 12 ++++ src/index.ts | 141 +++++++++++++++++++++++++++++++++----- src/server/panel-page.txt | 5 +- 6 files changed, 158 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 8a2ec81..f501636 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ services: 套餐类型(`plan`: `coding-plan` 个人套餐 / `start-plan` 体验套餐)在面板里按 <kbd>t</kbd> 切换,会写回 config.yaml。 -服务器这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动;命令走进程内分发,不会再额外开一个控制端口。 +服务器这类没有 TUI 的场景,可以让浏览器来看:设 `ZCODE_PANEL_ENABLED=1`、`ZCODE_PANEL_TOKEN=<一段你自己的随机串>` 后启动,再用 SSH 端口转发打开 `http://127.0.0.1:8090` —— 能看状态和额度、切服务商/套餐、登录登出、看实时日志和 MCP 列表。面板只绑回环、每次调 API 都要带 token,没有 token 不启动;命令走进程内分发,不会再额外开一个控制端口。面板上的「Stop proxy」只停代理,进程本身仍能正常退出(SIGTERM/SIGINT 和面板的 shutdown 都会先清掉后台定时器——自动领取、验证码池——再退出);在面板里登出会同时清掉运行中的凭据并停掉代理,避免登出后新请求还继续花旧账号的额度。 **Docker 里怎么连面板**:面板只监听**容器自己的** `127.0.0.1`,所以默认 bridge 网络下 `-p 8080:8080` 映射不出来,只补一个 `-p 8090:8090` 也连不上(端口映射到的是容器的非回环地址)。Linux 服务器上用 host 网络,让容器直接用宿主机回环: diff --git a/README_EN.md b/README_EN.md index ff88ee3..c00c54b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -188,7 +188,7 @@ The config file is `config.yaml` in the project root (auto-generated on first st The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggled in the panel with <kbd>t</kbd>, which writes the change back to config.yaml. -Without a TUI (cloud server) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=<your own random string>`, start the proxy, then forward the port and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. Commands are dispatched in process, so no extra control port is opened. +Without a TUI (cloud server) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=<your own random string>`, start the proxy, then forward the port and open `http://127.0.0.1:8090` — it shows status and quota, switches provider/plan, logs in and out, and tails the live logs plus the MCP list. The panel binds loopback only and requires the token on every API call; without a token it does not start. Commands are dispatched in process, so no extra control port is opened. Stopping the proxy from the page does not keep the process alive: SIGTERM/SIGINT and the panel's own shutdown all clear the background timers (auto-claim, captcha pool) before exiting. Logging out from the page also clears the live credential and stops the proxy, so a logged-out account is not spent any further. **Reaching the panel from Docker**: the panel listens on the *container's own* `127.0.0.1`, so with the default bridge network `-p 8080:8080` does not expose it, and adding `-p 8090:8090` does not help either (that maps a non-loopback container address). On a Linux server, use host networking so the container shares the host's loopback: diff --git a/src/auth/manager.test.ts b/src/auth/manager.test.ts index c759581..8d79959 100644 --- a/src/auth/manager.test.ts +++ b/src/auth/manager.test.ts @@ -66,4 +66,19 @@ describe("AuthManager", () => { await expect(mgr.getCredential()).rejects.toThrow(/expired/); await expect(mgr.getCredential()).rejects.toThrow(/not available/); }); + + it("drops the credential on clearOAuthCredential", async () => { + const mgr = new AuthManager(); + mgr.setOAuthCredential({ apiKey: "oa", provider: "zai" }); + mgr.clearOAuthCredential(); + await expect(mgr.getCredential()).rejects.toThrow(/not available/); + }); + + it("accepts a fresh credential after clearOAuthCredential", async () => { + const mgr = new AuthManager(); + mgr.setOAuthCredential({ apiKey: "old", provider: "zai" }); + mgr.clearOAuthCredential(); + mgr.setOAuthCredential({ apiKey: "new", provider: "zai" }); + expect((await mgr.getCredential()).apiKey).toBe("new"); + }); }); diff --git a/src/auth/manager.ts b/src/auth/manager.ts index c6264b9..4c97414 100644 --- a/src/auth/manager.ts +++ b/src/auth/manager.ts @@ -36,4 +36,16 @@ export class AuthManager { setOAuthCredential(cred: Credential): void { this.oauthCred = cred; } + + /** + * Drop the in-memory credential without touching the store. + * + * Used when the user logs out while the process keeps running (the `serve` + * web panel): this manager is consulted before the store, so a credential + * that is already gone from disk would otherwise keep being spent by `/v1` + * requests and by auto-claim. + */ + clearOAuthCredential(): void { + this.oauthCred = null; + } } diff --git a/src/index.ts b/src/index.ts index 5e9f4c0..32fea61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { buildServerOptions } from "./server/server-options.js"; import { resolvePanelSettings, startPanelServer, + type ControlDispatcher, type PanelServer, type PanelSettings, } from "./server/panel.js"; @@ -205,6 +206,11 @@ function installLogTee(): LogBuffer { * The proxy lifecycle hooks mirror `runAndroid` on purpose: `serve` starts the * proxy eagerly, so `serverRef` is pre-filled and the start/stop commands only * matter for restarts (including the `stop_proxy_first` rule before setConfig). + * + * Two behaviours are panel-only and stay out of the shared control layer: the + * `shutdown` command unwinds the whole process (through the same path as the + * signals, so it works after the proxy was stopped from the page), and a + * logout/login re-syncs the live credential — see `handleControl` below. */ async function startServePanel( settings: PanelSettings, @@ -214,9 +220,33 @@ async function startServePanel( auth: AuthManager; serverRef: { current: ProxyServer | null }; logBuffer: LogBuffer; + /** Unwind the process; independent of whether the proxy is still running. */ + shutdown: () => void; }, ): Promise<PanelServer> { - const { config, path, auth, serverRef, logBuffer } = ctx; + const { config, path, auth, serverRef, logBuffer, shutdown } = ctx; + + // The panel can log out (or log in another account) while `serve` keeps + // running, but AuthManager caches the credential in memory and auto-claim + // prefers it over the store — so a disk-only change would leave `/v1` and + // auto-claim serving the account that was just replaced (issue #58 review, + // P2). Fingerprint the store and re-sync after every panel command: the + // page polls `getLogs` every 2s, so a background login lands within one poll. + let authFingerprint = JSON.stringify((await loadCredential().catch(() => null)) ?? null); + + async function syncAuthWithDisk(): Promise<void> { + const onDisk = await loadCredential().catch(() => null); + const fingerprint = JSON.stringify(onDisk ?? null); + if (fingerprint === authFingerprint) return; + authFingerprint = fingerprint; + if (onDisk) { + auth.setOAuthCredential(onDisk); + console.log("auth: switched to the account now on disk"); + } else { + auth.clearOAuthCredential(); + console.log("auth: credential cleared (logged out)"); + } + } const controlState: ControlState = { provider: config.provider, @@ -229,6 +259,7 @@ async function startServePanel( const cred = await loadCredential().catch(() => null); if (!cred) return { ok: false, error: "not_logged_in" }; auth.setOAuthCredential(cred); + authFingerprint = JSON.stringify(cred); try { const s = await startServer(buildServerOptions(config, auth, false)); serverRef.current = s; @@ -268,17 +299,47 @@ async function startServePanel( // token, so a second loopback listener would only add an unauthenticated way // to reach stopProxy / logout / shutdown (issue #58 review, P1) and a second // thing to clean up when the panel fails to start (P2, now structurally gone). - const handleControl = createControlDispatcher(controlState, { + const dispatchControl = createControlDispatcher(controlState, { logBuffer, onStartProxy: startProxy, onStopProxy: stopProxy, onSetConfig: setConfig, onQuota: () => collectQuotaSnapshot(config), - onShutdown: async () => { - serverRef.current?.stop(true); - }, }); + /** Grace period for the `shutdown` reply before `process.exit()` runs. */ + const SHUTDOWN_REPLY_GRACE_MS = 50; + + /** + * The panel's transport wrapper. Two panel-only responsibilities live here + * rather than in the shared control layer, so the Android protocol keeps its + * existing semantics: + * + * - `shutdown` answers first and unwinds afterwards. Exiting inside the + * command would truncate the reply the page is waiting for, and it unwinds + * through the same path as SIGTERM/SIGINT, so it works whether or not the + * proxy is still running (issue #58 review, P2). + * - Every other successful command re-syncs the live credential with the + * store, and a logout while the proxy runs stops it. Otherwise `/v1` and + * auto-claim keep spending the account that was just logged out (issue #58 + * review, P2). + */ + const handleControl: ControlDispatcher = async (cmd) => { + if (cmd.cmd === "shutdown") { + setTimeout(shutdown, SHUTDOWN_REPLY_GRACE_MS); + return { ok: true, event: "shuttingDown" }; + } + const res = await dispatchControl(cmd); + if (!res.ok) return res; + await syncAuthWithDisk(); + if (cmd.cmd === "logout" && serverRef.current) { + await stopProxy(); + controlState.proxyPort = 0; + console.log("panel: logout cleared the live credential — proxy stopped"); + } + return res; + }; + const panel = await startPanelServer({ port: settings.port, token: settings.token, @@ -319,17 +380,25 @@ async function serve(configPath: string | undefined, debug: boolean): Promise<vo const serverRef: { current: ProxyServer | null } = { current: server }; const url = `http://${server.hostname}:${server.port}`; console.log(`zcode-proxy listening on ${url}`); + // Handles for the background timers started below, so `shutdown` can clear + // them without the proxy handle being involved (issue #58 review, P2). + let claimScheduler: { stop: () => void } | null = null; + let captchaModule: { shutdownCaptcha: () => void } | null = null; + if (config.plan === "start-plan") { // Pre-solve the captcha token pool in the background so first requests // don't pay the full solve latency (in-process happy-dom backend). import("./proxy/captcha.js") - .then((m) => m.startCaptchaPool(config.identity.appVersion)) + .then(async (m) => { + captchaModule = m; + await m.startCaptchaPool(config.identity.appVersion); + }) .catch((err) => console.error(`[captcha] pool warmup failed: ${(err as Error).message}`)); } if (config.claim.enabled && config.claim.auto) { import("./claim/runtime.js") .then((m) => { - m.startAutoClaim(config, auth); + claimScheduler = m.startAutoClaim(config, auth); console.log(` claim: auto ON (poll ${Math.round(config.claim.pollIntervalMs / 1000)}s)`); }) .catch((err) => console.error(`[claim] scheduler failed to start: ${(err as Error).message}`)); @@ -344,6 +413,52 @@ async function serve(configPath: string | undefined, debug: boolean): Promise<vo if (debug) console.log(` debug: ON`); let panelRuntime: PanelServer | null = null; + + const closePanel = (): void => { + if (!panelRuntime) return; + void panelRuntime.close().catch(() => {}); + }; + + // Single shutdown path, shared by the signals and the panel's `shutdown` + // command. It must not depend on `serverRef`: the page can stop the proxy, + // and the timers below keep the event loop alive, so "the proxy is already + // stopped" is not the same as "there is nothing left to do" — without this, + // SIGTERM/SIGINT and `docker stop` hung until the kill timeout after a + // panel-side Stop proxy (issue #58 review, P2). + let shuttingDown = false; + const shutdown = (): void => { + if (shuttingDown) return; + shuttingDown = true; + closePanel(); + const cleared: string[] = []; + if (claimScheduler) { + try { + claimScheduler.stop(); + cleared.push("auto-claim"); + } catch { + /* already stopped */ + } + claimScheduler = null; + } + if (captchaModule) { + try { + captchaModule.shutdownCaptcha(); + cleared.push("captcha pool"); + } catch { + /* pool never started */ + } + captchaModule = null; + } + if (cleared.length > 0) console.log(`shutdown: cleared ${cleared.join(" + ")} timers`); + if (serverRef.current) { + // Closes the listener and exits the process (`stop(true)`). + serverRef.current.stop(true); + return; + } + console.log("shutdown: proxy already stopped — exiting"); + process.exit(0); + }; + if (panelSettings && panelLogBuffer) { try { panelRuntime = await startServePanel(panelSettings, { @@ -352,6 +467,7 @@ async function serve(configPath: string | undefined, debug: boolean): Promise<vo auth, serverRef, logBuffer: panelLogBuffer, + shutdown, }); } catch (err) { // The panel is a convenience layer; it must never take the proxy down. @@ -359,19 +475,12 @@ async function serve(configPath: string | undefined, debug: boolean): Promise<vo } } - const closePanel = (): void => { - if (!panelRuntime) return; - void panelRuntime.close().catch(() => {}); - }; - process.on("SIGINT", () => { console.log("\nShutting down..."); - closePanel(); - serverRef.current?.stop(true); + shutdown(); }); process.on("SIGTERM", () => { - closePanel(); - serverRef.current?.stop(true); + shutdown(); }); } diff --git a/src/server/panel-page.txt b/src/server/panel-page.txt index 0ce1c56..90f8877 100644 --- a/src/server/panel-page.txt +++ b/src/server/panel-page.txt @@ -391,7 +391,10 @@ $("login").onclick = () => startLogin("zai"); $("login-bm").onclick = () => startLogin("bigmodel"); $("logout").onclick = () => { - if (confirm("Log out and delete the stored credential?")) guard("logout", { cmd: "logout" }); + if ( + confirm("Log out and delete the stored credential? The proxy stops using it immediately and is stopped if it is running.") + ) + guard("logout", { cmd: "logout" }); }; $("logs-clear").onclick = () => { $("logs").textContent = ""; };