diff --git a/README.md b/README.md index c488a9a..f501636 100644 --- a/README.md +++ b/README.md @@ -181,9 +181,36 @@ 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`) | 套餐类型(`plan`: `coding-plan` 个人套餐 / `start-plan` 体验套餐)在面板里按 t 切换,会写回 config.yaml。 +服务器这类没有 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 网络,让容器直接用宿主机回环: + +```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。 +
@@ -193,7 +220,7 @@ services: **周末/体验套餐自动领取 (claim)** —— 默认开启。代理每 5 分钟探测一次官方的限量套餐活动页,上新瞬间自动帮你抢(`claim.enabled: false` 可关闭)。手动抢:`bun run src/index.ts claim`。 -**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 r 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的剩余 / 总量与重置时间)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。 +**额度显示 (quota)** —— 登录后面板会自动查一次额度,之后按 r 手动刷新。数据来自上游两个额度平面:体验/积分制套餐的积分桶(`billing/balance`,剩余 / 总额、到期时间),以及个人编码套餐的用量窗口(`/api/monitor/usage/quota/limit`,与官方用量面板同源,5 小时 / 周窗口的**剩余额度**与重置时间——上游 `number` 不是可与剩余比较的总量,所以与 CLI/TUI 一致只显示剩余,只有上游给出百分比时才画比例条)。命令行直接查:`bun run src/index.ts quota`(对应 HTTP 接口 `GET /quota`)。注意上游网关对频繁查询有限速,所以面板不做定时轮询。
diff --git a/README_EN.md b/README_EN.md index ac1da4b..c00c54b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -182,9 +182,36 @@ 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) | 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) you can use a browser instead: set `ZCODE_PANEL_ENABLED=1` and `ZCODE_PANEL_TOKEN=`, 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: + +```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. +
@@ -194,7 +221,7 @@ The plan type (`plan`: `coding-plan` personal / `start-plan` trial) can be toggl **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 r. 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 r. 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.
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 { + return (cmd) => dispatch(cmd, state, ctx); +} + /** Context passed to `handleControlRequest` for hook wiring + log access. */ export interface HandlerContext { onStartProxy?: () => Promise; 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 b604e88..32fea61 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"; @@ -17,6 +22,13 @@ 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 ControlDispatcher, + 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 +178,178 @@ Examples: `); } +/** + * 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`. `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 + * 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, + ctx: { + config: ProxyConfig; + path: string; + auth: AuthManager; + serverRef: { current: ProxyServer | null }; + logBuffer: LogBuffer; + /** Unwind the process; independent of whether the proxy is still running. */ + shutdown: () => void; + }, +): Promise { + 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 { + 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, + 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); + authFingerprint = JSON.stringify(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 }; + } + + // 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 dispatchControl = createControlDispatcher(controlState, { + logBuffer, + onStartProxy: startProxy, + onStopProxy: stopProxy, + onSetConfig: setConfig, + onQuota: () => collectQuotaSnapshot(config), + }); + + /** 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, + handleControl, + }); + console.log(`panel: http://${panel.hostname}:${panel.port} (token required)`); + + return panel; +} + async function serve(configPath: string | undefined, debug: boolean): Promise { const path = configPath ?? process.env.ZCODE_PROXY_CONFIG ?? "config.yaml"; if (ensureConfigFile(path)) { @@ -175,6 +359,11 @@ async function serve(configPath: string | undefined, debug: boolean): Promise 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}`)); @@ -212,12 +412,75 @@ async function serve(configPath: string | undefined, debug: boolean): Promise { + 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, { + config, + path, + auth, + serverRef, + logBuffer: panelLogBuffer, + shutdown, + }); + } catch (err) { + // The panel is a convenience layer; it must never take the proxy down. + console.error(`[panel] failed to start: ${(err as Error).message}`); + } + } + process.on("SIGINT", () => { console.log("\nShutting down..."); - server.stop(true); + shutdown(); }); process.on("SIGTERM", () => { - server.stop(true); + shutdown(); }); } diff --git a/src/server/panel-page.txt b/src/server/panel-page.txt new file mode 100644 index 0000000..90f8877 --- /dev/null +++ b/src/server/panel-page.txt @@ -0,0 +1,414 @@ + + + + + + + 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..dc87074 --- /dev/null +++ b/src/server/panel.test.ts @@ -0,0 +1,350 @@ +/** + * Tests for the optional web panel (issue #58): token enforcement, the + * 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 { + DEFAULT_PANEL_PORT, + PANEL_ENABLED_ENV, + PANEL_PORT_ENV, + PANEL_TOKEN_ENV, + isPanelEnabled, + resolvePanelSettings, + 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"; + +/** Same shape the serve entry passes to `startPanelServer`. */ +type Dispatcher = (cmd: ControlCommand) => Promise; + +let panels: PanelServer[] = []; + +afterEach(async () => { + await Promise.all(panels.map((panel) => panel.close().catch(() => {}))); + panels = []; +}); + +/** Panel bound to a free port; the token is always the test token. */ +async function startPanel( + handleControl: Dispatcher = async () => ({ ok: true, event: "proxyStopped" }), +): Promise { + const panel = await startPanelServer({ port: 0, token: TOKEN, handleControl }); + panels.push(panel); + return panel; +} + +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("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, + }); + }); + + it("honours an explicit panel port", () => { + expect( + resolvePanelSettings({ + [PANEL_ENABLED_ENV]: "true", + [PANEL_TOKEN_ENV]: TOKEN, + [PANEL_PORT_ENV]: "9100", + }), + ).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(); + }); +}); + +describe("startPanelServer", () => { + it("refuses to start without a token", async () => { + 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: " ", handleControl })).rejects.toThrow( + /panel token required/, + ); + }); + + it("binds loopback on a free port and reports the real one", async () => { + 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(); + 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(); + + 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("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(); + + 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 without dispatching anything", async () => { + let calls = 0; + const panel = await startPanel(async () => { + calls++; + return { ok: true, event: "proxyStopped" }; + }); + + 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(calls).toBe(0); + }); + + 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" } }); + + const viaBearer = await controlRequest(panel, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(viaBearer.status).toBe(200); + + expect(seen).toEqual([{ cmd: "quota" }, { cmd: "status" }]); + }); + + 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: JSON.stringify({ cmd: "nope" }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: false, error: "unknown_cmd: nope" }); + }); + + 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 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 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}` }, + body: "x".repeat(70 * 1024), + }); + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ ok: false, error: "request_too_large" }); + 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 new file mode 100644 index 0000000..96e038d --- /dev/null +++ b/src/server/panel.ts @@ -0,0 +1,277 @@ +/** + * 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 → 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; + * - 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`, 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. */ +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"; + +/** Defaults mirror the Android entry's wiring so operators only set one thing. */ +export const DEFAULT_PANEL_PORT = 8090; + +/** Control commands are small JSON documents; anything bigger is a mistake. */ +const MAX_BODY_BYTES = 64 * 1024; + +/** + * 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; + /** In-process dispatcher backing `POST /api/control`. */ + handleControl: ControlDispatcher; + /** 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; +} + +/** 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, 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; + + 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; + return { token, port }; +} + +/** 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"); +} + +/** + * 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; + } + // 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; + } + + const body = await readBody(req); + if (body === null) { + sendJson(res, 413, { ok: false, error: "request_too_large" }); + 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 { + // 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) { + sendJson(res, 500, { ok: false, error: `internal_error: ${(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. 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(); + 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())); + }), + }; +}