import type { IncomingMessage, ServerResponse } from 'node:http'; /** * 工作台入口短时票据(P2-1)。 * * 背景:`/dashboard` 飞书卡片的「打开工作台」按钮曾直接把长期 Dashboard 管理 * token 拼进 URL 写入持久化卡片——管理员门禁只保护首次投递,覆盖不了卡片历史、 * 转发和截图,等于把常驻凭证抄送给未来所有能看到这张卡的人。改为:卡片构建时 * 现 mint 一张 **30 分钟 TTL 的不可预测票据**,按钮链接只携带票据;访客打开 * `GET /workbench-ticket/` 时由 dashboard 验票,通过则按既有 `?t=` 流程 * 同款种 legacy cookie 再 302 进工作台。到期后卡片历史/转发/截图里的链接一律 * 作废,只回一个无凭据的提示页。 * * 设计要点: * - **非一次性**:同一张卡片会被同一个管理员在 PC / 手机多端点开,票据在 TTL * 内可重复兑换,到期即死。不做兑换计数,避免飞书链接预抓取把票据「烧」掉。 * - **绑定 token generation(P1-6)**:见下方「票据 ↔ token generation」小节。 * - **跨进程**:mint 发生在 daemon 进程(构建卡片处),验票发生在 dashboard * 进程,两边靠 `~/.botmux/.workbench-tickets.json` 交接;进程内 Map 只是 * 本进程的快路径缓存。落盘复用 secure-host-file 的 0600 + 目录 0700 + 泄漏 * 检查惯例(与 `.dashboard-token` 同一套),读-并-写走跨进程文件锁,杜绝并发 * mint / prune 互相丢条目。 * - **文件里绝不存明文**:只存 `sha256(ticket)` 的 base64url + 过期时间 + * generation 标签。拿到文件读权限的人无法还原票据;但能**写**这个文件就等于 * 能自铸门票,所以文件的安全形状(属主 / 0600 / 拒符号链接)由 secure-host-file * 强制,不满足即 fail closed。 * - **重启不废票**:dashboard 重启后 Map 为空,验票落到文件比对 hash,刚发出 * 的卡片链接照常可用。 * * ─── 票据 ↔ token generation(P1-6)──────────────────────────────────────── * 票据兑换出来的是「**当前**活跃 token」的 cookie。只按 TTL 判活时,rotate 之前 * 泄漏出去的票据能在 rotate 之后继续兑换——而且兑出来的是**新**管理 cookie。若 * rotate 的原因正是「链接/卡片泄漏了」,rotation 就此失去全部安全语义:管理员轮 * 换了凭证,攻击者拿旧票据无缝续上新凭证。 * * 所以每张票在 mint 时钉住当时 token 的 generation 标签(`sha256("…" + token)`), * 兑换时要求这个标签仍等于**此刻即将下发的那份 token** 的标签。rotate 换掉 token * ⇒ 标签变 ⇒ 旧 generation 的票据一律 410。这条判定是构造性的:不依赖 rotate * 路径记得去清理存量票据,清理({@link revokeWorkbenchTicketsOutsideGeneration}) * 只是把死条目从文件里扫掉的保洁。 * * 同一 generation 内仍然**不是**一次性票:多端重复兑换的语义(P2-1)原样保留。 * * ─── 公开兑换端点的 DoS 面(P1-10)──────────────────────────────────────── * 兑换端点在 auth gate 之前放行(票据自身就是凭证),任何人都能打。旧实现里每个 * 「形状合法但未知」的票据都会同步 realpath/stat/open/read/fstat 一遍凭证文件再 * 全表扫描——HOME 挂在 NFS 上时,几百个伪造票据就能把 event loop 焊死。现在三层 * 一起收口,且都保持 fail closed 与 timingSafeEqual 语义: * 1. **限流**:每 IP + 全局滑动窗口(复用 H5 兑换口那套 gate 与可信代理取 IP * 口径),拒绝在读任何盘之前发生; * 2. **快照**:落盘内容在进程内缓存 {@link STORE_SNAPSHOT_TTL_MS},打盘次数只跟 * 时间走、不跟请求量走——请求量翻一万倍,打盘次数不变; * 3. **负缓存**:验失败的 (票据 hash, generation) 短期记住,重复打同一张票连 * 全表扫描都省掉。负缓存条目钉住快照版本号,快照内容一变即失效,所以「刚 * mint 出来的新票被前一次探测顺手拉黑」最多只存在一个快照周期。 */ /** 票据有效期:30 分钟。够管理员从卡片点进去(含转发到手机再点),又短到卡片 * 历史里的旧链接很快失效。 */ export declare const WORKBENCH_TICKET_TTL_MS: number; /** 兑换端点路径:`GET /workbench-ticket/`。auth.ts 的公共面豁免与这里 * 必须保持同一个 pattern(那边按 `/^\/workbench-ticket\/[^/]+$/` 放行)。 */ export declare const WORKBENCH_TICKET_ROUTE_RE: RegExp; /** 兑换端点限流:滑动窗口长度。 */ export declare const WORKBENCH_TICKET_RATE_WINDOW_MS = 60000; /** 单 IP 每窗口兑换次数上限。管理员多端打开 + 客户端链接预抓取远在这之下。 */ export declare const WORKBENCH_TICKET_MAX_PER_IP_PER_WINDOW = 20; /** 端点级每窗口上限:换一堆源地址也绕不开这个天花板。 */ export declare const WORKBENCH_TICKET_MAX_GLOBAL_PER_WINDOW = 120; /** sha256 → base64url。导出仅为测试断言「文件里只有 hash 没有明文」。 */ export declare function hashWorkbenchTicket(ticket: string): string; /** 尚无活跃 token 时的 generation 标签。此时 mint 出的票据也只能在「仍然没有 * token」的状态下兑换(兑不出 cookie,等于把用户送到登录墙),一旦 dashboard * 发出了第一份 token,这些票即刻作废——fail closed 方向。 */ export declare const WORKBENCH_TICKET_NO_TOKEN_GENERATION = "none"; /** * token 的 generation 标签:`sha256("workbench-ticket-generation:" + token)`。 * * 只用于相等比较,不是凭证:它不可逆推 token(token 本身是高熵随机串),而且和 * 票据 hash 一起躺在同一个 0600 凭证文件里,不扩大任何暴露面。用 hash 而不是 * 计数器,是为了让 daemon(mint 侧)与 dashboard(验票侧)无需共享任何额外状态 * ——两边各自从同一份 token 现算即可,天然跨进程、跨重启一致。 */ export declare function workbenchTicketGeneration(token: string | null | undefined): string; /** * 从 `~/.botmux/.dashboard-token` 现算当前 generation。返回 `null` 表示**读不出 * 来**(目录/文件形状不安全等)——调用方一律 fail closed:mint 抛出(卡片退化成 * 无凭证登录链接),验票返回 false。「文件不存在」不算读不出来,它是合法的 * 「还没有 token」状态,归一到 {@link WORKBENCH_TICKET_NO_TOKEN_GENERATION}。 */ export declare function currentWorkbenchTokenGeneration(): string | null; /** * Mint 一张新票据并落盘,返回**明文票据**(只出现在返回值和最终 URL 里,不进 * 日志不进文件)。落盘失败直接抛出——文件是 daemon(mint) 与 dashboard(验票) * 之间唯一的交接面,写不进去的票据必然兑换失败,调用方(workbench-link)捕获 * 后退化为不带票据的登录墙链接,而不是发出一个注定 404 的死链。 * * 读不出当前 token 的 generation 时同样抛出:宁可发无凭证链接,也不发一张绑不上 * generation、语义不明的票。 */ export declare function mintWorkbenchTicket(nowMs?: number): string; /** * 验票:格式门 → generation 门 → 进程内缓存 → 快照(跨进程 / 重启恢复路径)。 * 任何读盘异常(目录形状不安全、文件损坏)一律 fail closed 返回 false,绝不因为 * 存储出问题就放行。hash 比对用 timingSafeEqual 且**不提前退出**,不给远端留计时 * 侧信道。 * * `generation` 默认从磁盘现算;兑换端点会显式传入「即将下发的那份 token」的 * generation,把票据直接绑到真正要交出去的凭证上。传入 `null`(读不出来)即 * fail closed。 */ export declare function verifyWorkbenchTicket(ticket: string, nowMs?: number, generation?: string | null): boolean; /** * 定期清理:进程内 Map 直接清;落盘文件只在真有过期条目时才持锁重写(避免每个 * 节拍都空转写盘)。文件不存在 / 目录形状不安全时静默跳过——prune 是保洁工作, * 不能因为存储异常把进程打崩。 */ export declare function pruneExpiredWorkbenchTickets(nowMs?: number): void; /** * 作废「不属于 `generation` 这一代」的全部票据(P1-6)。`botmux dashboard rotate` * 落盘成功后调用,传入**新** token 的 generation。 * * 这是保洁,不是防线:真正的防线是验票时的 generation 相等判定,即使这里一次都 * 没跑过(进程崩了、文件临时不可写),旧票也一样兑不出新 cookie。所以整段吞异常 * ——rotate 绝不能因为清理失败而失败。 */ export declare function revokeWorkbenchTicketsOutsideGeneration(generation: string, nowMs?: number): void; /** 测试专用:清空进程内缓存、限流闸与定时器,模拟进程重启(文件保留)。 */ export declare function resetWorkbenchTicketStoreForTests(): void; /** 测试专用诊断:真正打盘次数与负缓存命中次数。`diskReads` 必须随请求量保持 * 平坦,否则说明请求路径又退回到「每次同步读凭证文件」。 */ export declare function workbenchTicketStoreStatsForTests(): { diskReads: number; negativeHits: number; negativeEntries: number; }; /** 过期 / 无效票据的提示页正文(测试断言用同一常量)。零凭据、零回显:不含 * token、不含来访票据本身,只告诉用户下一步怎么拿新入口。 */ export declare const WORKBENCH_TICKET_EXPIRED_MESSAGE = "\u94FE\u63A5\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u5728\u4F1A\u8BDD\u91CC\u91CD\u65B0\u53D1\u9001 /dashboard \u83B7\u53D6\u65B0\u5165\u53E3"; /** 触发限流时的提示页正文。同样零凭据、零回显,且不透露票据是否存在。 */ export declare const WORKBENCH_TICKET_RATE_LIMITED_MESSAGE = "\u5151\u6362\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5"; export interface WorkbenchTicketRedemptionDeps { /** 当前活跃的 legacy 管理 token(`null` = dashboard 还没发过号)。 */ activeToken: () => string | null; /** 反代跳数,语义同 {@link dashboardH5ClientIp}:0(默认)= 只认 socket 对端, * 完全不看客户端可伪造的 `x-forwarded-for`。 */ trustedProxyHops?: number; } /** * `GET /workbench-ticket/` 兑换处理器。命中路径返回 true(响应已写完), * 未命中返回 false 交还路由。语义对齐既有 `?t=` 流程(auth.ts 的 * `allow+set-cookie` 分支):验票通过 → 种同一个 legacy cookie → 302 进工作台。 * * - 触发限流:429 + Retry-After + 无凭据提示页(在读任何盘之前就返回,见文件 * 顶部 P1-10)。名额按尝试消耗,与 LocateRateLimiter 一致。 * - 票据有效(含 generation 命中)且有活跃 token: * `Set-Cookie: botmux_dashboard_token=` + 302 `/#/agent-workbench`。 * - 票据有效但当前没有活跃 token(dashboard 从未发号):302 进工作台但不种 * cookie,用户面对正常登录墙。这是旧「读不到 token 就发裸链接」行为的对位, * 绝不在匿名请求侧顺手铸造新 token。 * - 票据无效 / 过期 / 属于 rotate 之前那一代:410 + 无凭据中文提示页。 * * 所有响应一律 no-store:兑换 URL 本身携带凭据性质的票据,任何中间缓存都不该 * 存它的响应。 */ export declare function handleWorkbenchTicketRedemption(req: IncomingMessage, res: ServerResponse, url: URL, deps: WorkbenchTicketRedemptionDeps): boolean; //# sourceMappingURL=workbench-ticket.d.ts.map