/** * P1-12:Preview 端口的「持有者归属证明」。 * * 背景:`botmux preview ` 过去只做一次 TCP connect 就把 (host, port) 存成 * 会话的 previewTarget。connect 成功只证明**此刻有人在监听这个号码**,不证明 * 监听者是本会话的进程: * - agent 可以注册 Docker API、本机数据库、别的项目的 dev server、甚至宿主的 * 管理口,Dashboard 登录用户随后被同源代理进去; * - 自己的 dev server 退出后端口号被内核回收,下一个抢到它的**任意**本机进程 * 就继承了这条已经登记的预览路由。 * * 所以注册时必须留下可复核的「谁在持有」证据,并且在 probe 与每次代理落地前重新 * 核对。这里给出的证据是: * * inode — 监听 socket 在内核里的唯一编号。socket 关闭即释放,新监听者一定拿到 * 新的 inode,所以 inode 不变 ⇔ 还是当初那一个 listen socket。 * pid — 持有该 inode 的进程。 * procStart — 该进程的 `starttime`(/proc//stat 第 22 字段),用来抵御 pid * 复用:pid 相同但 starttime 变了就是另一个进程。 * * 归属(而不仅仅是「稳定」)由血缘判定:持有者必须落在本会话的进程血缘里——从 * worker fork 的 pid、worker 通过私有 IPC 上报的 CLI pid(tmux/zellij 这类后端 CLI * 不是 worker 的子进程,必须单独作根)出发向下遍历 ppid 树。不在血缘里 → 拒绝注册, * 宁可没有预览,也不把登录用户代理进一个来路不明的本机服务。 * * 依赖与边界(务必读): * - **只在 Linux procfs 上成立**。/proc/net/tcp{,6} 给 inode,/proc//fd 给 * 持有者,/proc//stat 给 ppid + starttime。daemon 实跑在 Linux;其它平台 * 拿不到等价的强证明,`resolvePreviewPortOwner` 直接返回 platform_unsupported, * 调用方 fail closed(注册失败、不显示预览),而不是降级成「connect 通过就信」。 * - **同一 network namespace**。/proc/net/tcp 是 netns 局部的;daemon、dashboard * 与被预览的 dev server 都在同一 netns 才有意义(预览本来就只代理 loopback, * 跨 netns 本就不可达)。 * - **注册与复核的权限要求不同**。注册要扫 /proc//fd,需要与目标进程同 uid * (daemon 扫自己的子孙进程,天然满足);复核只读 /proc/net/tcp{,6} 与 * /proc//stat,这两者对全体用户可读,所以 dashboard 进程即便与 daemon 不同 * 用户也能在每次代理前复核。 */ export declare const DEFAULT_PROC_ROOT = "/proc"; /** 持有证明。随 previewTarget 一起持久化,因此必须是纯 JSON 且可再校验。 */ export interface PreviewPortOwnerProof { /** 持有监听 socket 的进程 pid。 */ pid: number; /** /proc//stat 的 starttime 字段,抵御 pid 复用。 */ procStart: string; /** 监听 socket 的内核 inode(十进制字符串)。 */ inode: string; } export type PreviewPortOwnerFailure = /** 非 Linux:拿不到等价强证明,调用方必须 fail closed。 */ 'platform_unsupported' /** procfs 读不到(容器裁剪 /proc、权限、netns 不同)。 */ | 'proc_unreadable' /** 这个 host:port 上根本没有 LISTEN socket。 */ | 'no_listener' /** 同等匹配度下有多个监听 socket(SO_REUSEPORT),无法确定代理会落到哪一个。 */ | 'ambiguous_listener' /** 有人在监听,但持有者不在本会话的进程血缘里(或读不到其 fd)。 */ | 'owner_unknown'; export type PreviewPortOwnerResolution = { ok: true; proof: PreviewPortOwnerProof; } | { ok: false; reason: PreviewPortOwnerFailure; }; /** * 复核结论。`changed` 与 `unverifiable` 都必须按失效处理——区分它们只为把原因写 * 进日志/错误码,不是为了给任何一种放行。 */ export type PreviewPortOwnerVerdict = 'ok' | 'changed' | 'unverifiable'; export declare function safePreviewPortOwnerProof(value: unknown): PreviewPortOwnerProof | undefined; /** * 会话血缘:从给定的根 pid 出发,收集所有仍然活着的子孙进程。 * * 用一次 /proc 全量 ppid 扫描建反向索引,而不是逐层 fork `pgrep`:注册是低频动作, * 一次几百个 stat 读远比拉子进程便宜,也不引入外部命令依赖。 * * 被 daemon 化(setsid / 双 fork)而被 init 收养的 dev server 会脱离血缘——那正是 * 这里要拒绝的一类:它已经不能证明自己属于本会话。 */ export declare function collectSessionLineagePids(procRoot: string, roots: number[]): Set | undefined; /** * 注册时求取持有证明。必须在 daemon 进程内调用(需要读会话子孙进程的 fd)。 */ export declare function resolvePreviewPortOwner(input: { host: string; port: number; /** 会话血缘的根 pid:worker fork pid、worker 上报的 CLI pid、adopt 的 CLI pid 等。 */ ownerPids: Array; procRoot?: string; }): PreviewPortOwnerResolution; /** * 复核持有证明。probe 之后与**每次代理落地之前**都要跑一遍。 * * 只读全体可读的 /proc/net/tcp{,6} 与 /proc//stat:dashboard 进程即使与 daemon * 不同用户也能复核。成本是每跳一次 procfs 表读取(page cache 命中,无系统调用之外的 * I/O);这里不做结果缓存——缓存窗口就是「端口已经易主但仍在代理」的窗口。判定 `ok` * 需要同时满足: * 1. 该 host:port 现在的监听 socket 还是当初那个 inode(socket 没被关过); * 2. 当初那个 pid 还活着,且 starttime 未变(不是 pid 复用后的另一个进程)。 */ export declare function verifyPreviewPortOwner(input: { host: string; port: number; proof: PreviewPortOwnerProof; procRoot?: string; }): PreviewPortOwnerVerdict; //# sourceMappingURL=preview-port-owner.d.ts.map