@jiangsier/local-spy
v0.2.0
Published
Local HTTP traffic inspector for macOS — passive capture with an opt-in MITM HTTPS proxy and Chrome-style Copy-as-cURL
Downloads
30
Maintainers
Readme
local-spy
English | 简体中文
local-spy 是一款 macOS 上的本地 HTTP 流量检查器:从你 Mac 上正在运行的进程中任选一个,即可在浏览器中实时查看它的 HTTP 请求与响应——请求头、报文主体、耗时、状态码——一切都在线路上发生的那一刻同步呈现。它就像 Chrome DevTools 的 Network 面板,但面向的是原生进程:CLI 工具、后台守护进程、构建工具、Electron 辅助进程,任何使用纯 HTTP/1.x 通信的程序。
无需配置代理。无需注入代码。后台也不会运行任何 root 守护进程。
安装
仅支持 macOS · 需要 Node.js ≥ 22.18 — 见环境要求。
从 npm 全局安装,然后启动:
npm install -g @jiangsier/local-spy
local-spy # 在 http://127.0.0.1:7634 提供 UI打开 http://127.0.0.1:7634,选定进程并按下 Capture —— 之后的操作与快速开始完全一致。用 Ctrl-C 停止 CLI;若 7634 被占用可使用 PORT=8080 local-spy,需要启用可选的 MITM 代理时使用 LOCAL_SPY_PROXY=1 local-spy(CLI 会继承你的环境变量,与 run.sh 采用 env -i 净化启动的方式不同)。
如需从源码运行(克隆、npm install、./scripts/run.sh),参见快速开始。
特性
- 进程选择器 — 实时列出运行中的进程及其 CPU/内存占用与 TCP 端口数;选定一个 pid,点击 Capture。
- 实时流量表 — 每一次 HTTP 交换(方法、URL、状态、大小、耗时、TLS 徽标)均经 WebSocket 流式推送,pending → complete 状态转换实时呈现。
- 完整的请求/响应详情 — 请求头、解码后的报文主体(透明处理 gzip/chunked)、二进制主体以 base64 呈现、MIME 类型。
- 右键任意请求 — 每行都提供与 Chrome DevTools 一致的右键菜单:打开详情、复制 URL,或按 Chrome 的命令约定复制为 cURL(URL 在前、Cookie 走
-b、不带请求体的 GET 与带请求体的 POST 不输出-X)。 - 真正的数据包捕获 — 基于
tcpdump通过 macOS BPF 设备实现:没有任何伪造内容;在lo0+en0上执行 classic-pcap 捕获(pktap pid 归属已实现,但在 Apple 的 tcpdump 上处于休眠状态——见“限制”)。 - 按目标进程限定范围 — 通过
lsof端口集进行准入控制:只有属于所选进程的流量才会被重组并展示。 - 健壮的 TCP 重组 — 序号回绕、重传、乱序分节、中途接入、FIN/RST 拆除;未获准的流会被缓存,获准后随即回放。
- 设计上即安全 — 服务器绝不以 root 运行;仅有一个约 290 行、可完整审计的辅助脚本会通过 Apple 自带的授权对话框获得权限提升。
- 一条命令管理生命周期 —
./scripts/run.sh/./scripts/stop.sh,附带 pidfile 管理、过期构建检测以及遗留 root 进程检查。
环境要求
| 要求 | 说明 |
|---|---|
| macOS 12+ | 使用 BPF 设备、tcpdump、lsof、osascript(均随 macOS 附带) |
| Node.js ≥ 22.18 | 在 package.json 的 engines 中声明;版本不匹配时 npm 只会发出警告(EBADENGINE),除非设置了 engine-strict——可用 node --version 核实 |
| 你的管理员密码 | 由 macOS(而非 local-spy)在每个捕获会话中询问一次,用于授权抓包 |
| GUI 会话 | 授权对话框需要窗口服务器——请从 Terminal.app 运行,而不是无图形界面的 SSH |
快速开始
npm install # once
./scripts/run.sh # builds if needed, starts local-spy on http://127.0.0.1:7634注:
react、MUI 与i18next位于devDependencies,因为 SPA 已预先构建——npm install --omit=dev加上npm start就足以运行服务器并托管已构建好的 UI;完整安装 +npm run build仅在进行 Web 开发时才需要。
打开 http://127.0.0.1:7634,从列表中选定一个进程,然后按下 Capture:
- macOS 弹出标准的管理员密码对话框(这是 Apple 的授权 UI——local-spy 永远看不到你的密码)。请点击批准。
- 该进程的流量会在一两秒内开始流入表格。
- 检查各条目;完成后按下 Stop。
结束时:
./scripts/stop.sh # stops capture + server, cleans runtime files (keeps logs)run.sh 是幂等的——运行两次只会提示 "already running"。如果 7634 端口已被占用,可使用 PORT=8080 ./scripts/run.sh。
工作原理
┌────────────┐ REST + WS ┌─────────────────────────── server (never root) ───────────────────────────┐
│ web UI │◄────────────►│ express + ws │
│ (browser) │ │ │ capture manager │
└────────────┘ │ │ 1. elevation: osascript "with administrator privileges" │
│ │ → runs scripts/capture-helper.sh (the ONLY root code) │
│ │ 2. helper starts tcpdump pktap/legacy → .run/pcap/*.pcap │
│ │ 3. pcap tailer follows the files as they grow │
│ │ 4. pcap → IP → TCP reassembly │
│ │ · admission gating by lsof port sets of the target pid │
│ │ · unadmitted flows buffered, replayed from stream start │
│ │ 5. HTTP/1.x reconstruction (http-parser-js) + gzip/chunked decoding │
│ │ 6. ring store (2000 entries) → WS broadcast to the UI │
└─────────────────────────────────────────────────────────────────────────────┘分步说明:
- UI → REST:浏览器选定一个 pid 并发出
POST /api/capture/start {"pid":1234}。 - 权限提升:服务器请求 macOS(通过
osascript)以管理员权限运行scripts/capture-helper.sh。Apple 的对话框弹出;你批准之后,以 root 运行的是这个辅助脚本——而非服务器。 - 捕获:辅助脚本按接口(
lo0+en0;Apple 的 tcpdump 会为pktap设备强制使用 PCAP-NG,因此 auto 模式直接选择该方式)启动tcpdump,BPF 过滤器排除 local-spy 自身的端口,并将 classic pcap 文件写入.run/pcap/。辅助脚本的每一步都会记录到.run/logs/capture-helper.log。 - 跟踪与重组:服务器持续 tail 这些 pcap 文件,解析链路层/IP/TCP,重组字节流,并按目标进程的
lsof端口集对流进行准入控制(每秒刷新一次,并配合回放缓存,因此新连接等待准入期间不会丢失任何数据)。 - HTTP 重建:字节流按 HTTP/1.x 解析;gzip 与 chunked 主体在服务器端解码;每条消息的主体上限为 512 KiB。
- 存储与推送:各次交换落入一个 2000 条目的环形缓冲区,并经 WebSocket 推送到浏览器。
授权与安全模型
local-spy 服务器绝不以 root 运行。 它以你的普通用户身份运行,仅绑定
127.0.0.1,并由./scripts/run.sh在净化后的环境(env -i)中启动。只有
scripts/capture-helper.sh以 root 运行 —— 一个约 290 行、可以一口气读完的 POSIX shell 脚本。它启动tcpdump、写入 pidfile、轮询 stopfile、然后退出。它经由osascript … with administrator privileges调用,也就是 Apple 的标准授权对话框——你的密码交给 macOS,永远不会交给 local-spy。管理员授权会被 macOS 短暂缓存(约 5 分钟),因此该时间窗内的连续捕获通常不会再次弹出提示。
停止操作从不需要 sudo。
./scripts/stop.sh(以及 UI 的 Stop 按钮)只需 touch.run/stop-capture.flag;root 辅助脚本每 0.5 秒轮询一次该文件,并终止自身及其 tcpdump。限定范围的清理。
stop.sh只会匹配命令行参数中引用了本项目.run/pcap的 tcpdump 进程——你自己无关的 tcpdump 会话绝不会收到信号。若发现遗留进程,它会打印出确切的清理命令:pgrep -fl 'tcpdump.*\.run/pcap' # check first sudo pkill -f 'tcpdump.*\.run/pcap' # kill local-spy's tcpdumps only手动回退。 如果你宁愿自行授权辅助脚本,而不是点击对话框(或对话框根本无法弹出),请在项目根目录运行:
sudo sh scripts/capture-helper.sh --pcap-dir .run/pcap --filter 'tcp and not port 7634 and not port 5173' --iface lo0 --stopfile .run/stop-capture.flag --seq 0然后在 UI 中按下 Capture——服务器会接管已在运行的捕获:它检测到存活的 tcpdump pidfile 以及
.run/pcap/下辅助脚本的 mode 文件后,会完全跳过自身的权限提升提示(不会出现第二个授权对话框),绝不截断或重建 pcap 文件,并从手动捕获迄今为止写入的内容继续 tail。Stop 的行为与平时完全一致——共享的 stopfile 同样会关闭手动启动的辅助脚本。这条一模一样的命令也内置于 Web UI 的帮助页面中。
隔离模型
local-spy 有意不使用 Docker/虚拟机:容器或虚拟机无法捕获宿主机的环回与物理接口流量——你检查到的将是错误的网络命名空间。隔离改由以下设计提供:
- 干净的环境:服务器通过
env -i启动,仅保留HOME、最小化的系统PATH与PORT,外加仅在调用方显式设置时透传的可选代理变量(LOCAL_SPY_PROXY、LOCAL_SPY_PROXY_PORT、NO_PROXY/no_proxy)——不继承其余任何 shell/开发工具链变量。 - 项目内的状态:所有运行时状态都存放在项目内的
.run/下(pid/、logs/、pcap/、stopfile)。不会在其他位置写入任何内容;.run/已被 gitignore。 - 专用端口:API/UI 监听 7634(可用
PORT=…覆盖);BPF 过滤器排除 local-spy 自身的端口,因此检查器无法捕获到自己。 - 仅环回地址:服务器只绑定
127.0.0.1——网络上的其他主机无法访问它。 - Pidfile 生命周期:
run.sh拒绝重复启动(存活 pid 检查),stop.sh在发送信号前会核实该 pid 确实属于 local-spy。
HTTPS MITM 代理(可选启用)
被动捕获只能把 TLS 流量显示为不透明的元数据(见限制)。当你需要看清 HTTPS 内部时,local-spy 内置了一个可选启用的本地 HTTPS 解密代理:被你显式指向它的客户端,其 TLS 流量会在本地被解密,解密后的完整交换——完整的请求/响应头与主体,外加 Copy-as-cURL——会与被动条目一同出现在同一个 UI 中。被动抓包行为保持不变,且该功能默认关闭。
启用代理
启动服务器时设置 LOCAL_SPY_PROXY=1——该标志(以及 LOCAL_SPY_PROXY_PORT、NO_PROXY)可以穿透 run.sh 的净化环境:
LOCAL_SPY_PROXY=1 ./scripts/run.sh # primary — managed launch (pidfile, log, ./scripts/stop.sh)
LOCAL_SPY_PROXY=1 npm start # alternative — dist must exist: npm run build (or one prior ./scripts/run.sh boot)代理在 127.0.0.1:7635 上监听 CONNECT(可用 LOCAL_SPY_PROXY_PORT 覆盖);启动日志中的 proxy: 一行会确认其状态。run.sh 启动的实例用 ./scripts/stop.sh 停止;直接启动的服务器(无 pidfile)用 Ctrl+C。
信任 CA(一次性)
代理通过出示由本地 CA 签发的逐主机证书来解密 TLS,该 CA 由 local-spy 在首次使用时生成。只有当该 CA 受到信任后,客户端才会接受这些证书——一次性设置,两种方式任选:
帮助页面(最简单):按下 Trust——CA 会被记入当前用户登录钥匙串的信任设置,无需管理员密码(若 macOS 弹出证书信任确认框,点击确认即可)。Untrust 可再次移除信任。API 在每步之后都会用
security verify-cert校验信任是否真正生效,失败会如实上报。手动,在项目根目录执行:
sh scripts/trust-ca.sh # 信任 CA(无需 sudo) sh scripts/untrust-ca.sh # 移除信任
CA 存放在 .run/ca/(已 gitignore;私钥权限 0600),并在重启后保留——请妥善保管。纯 Node 客户端不读取系统钥匙串,因此需要显式指向该 CA:
NODE_EXTRA_CA_CERTS="$(pwd)/.run/ca/ca.pem" node your-client.js将客户端指向代理
先做一个快速健全性检查(在项目根目录执行——直接信任该 CA,无需写入钥匙串):
curl --cacert .run/ca/ca.pem -x http://127.0.0.1:7635 https://example.com/ -o /dev/null不同应用支持的代理机制各不相同——按以下顺序逐档尝试,并实证验证你的应用响应哪一档:
Node ≥ 24 —— 携带代理环境变量重新启动客户端:
NODE_USE_ENV_PROXY=1 HTTPS_PROXY=http://127.0.0.1:7635 HTTP_PROXY=http://127.0.0.1:7635 NO_PROXY=localhost,127.0.0.1 node your-client.jsElectron / Chromium —— 向应用可执行文件传入启动开关:
your-app --proxy-server=http://127.0.0.1:7635macOS 系统代理(最后手段——影响机器上的所有应用):
networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 7635 networksetup -setsecurewebproxystate "Wi-Fi" off # revert when done
已在本机(macOS 26)实证验证:curl 携带 --cacert .run/ca/ca.pem 可以工作,且在通过 Trust 按钮信任 CA(用户域钥匙串信任)之后,即使不带该参数也能工作;Node ≥ 24 的 fetch 恰好需要 NODE_USE_ENV_PROXY=1 + HTTPS_PROXY/HTTP_PROXY(外加 NODE_EXTRA_CA_CERTS)——已在 Node 25.2.1 上验证——缺少该标志时 fetch 会静默绕过代理。
安全须知
- 解密后的机密会变得可见。 解密流量中的一切——包括
Authorization与Cookie请求头——都会显示在本应用中,并被原样嵌入导出的 cURL 命令,不做任何脱敏(与浏览器 DevTools 的 "Copy as cURL" 语义相同)。请将条目与导出内容视为敏感信息。 - 妥善保管
.run/ca/。 任何持有 CA 私钥的人,都可以对信任该 CA 的客户端冒充任意主机。 - 仅限单机使用。 切勿共享该 CA,也不要在共享机器上启用此功能。
- 证书固定(certificate pinning)。 执行证书固定的应用会拒绝代理签发的叶子证书;此时 local-spy 会回退为不透明隧道(条目仅保留元数据),而不会破坏应用。
- 默认关闭,正是出于以上全部原因。
工作原理
代理拦截 HTTP CONNECT 隧道,用本地 CA 按需签发的逐主机叶子证书(RSA-2048、30 天有效期)终结 TLS,两侧均使用 HTTP/1.1,并向真实上游重新加密。主体语义与被动管线完全一致——每条消息保留 512 KiB 前缀、byteSize 为真实线上总字节数、截断会被标记——解密后的交换会落入与被动捕获相同的 2000 条目环形缓冲区。
代理集成测试作为 npm test 的一部分运行(见测试)。
卸载 / 回滚
回滚代码不会清除机器上的状态。无论在回滚之前还是之后执行,都请移除 CA 信任并删除 CA 与私钥:
sh scripts/untrust-ca.sh # if the script is already gone:
security remove-trusted-cert -d .run/ca/ca.pem # ...remove the trust record directly
rm -rf .run/ca # deletes the CA + private key遗留的受信任 CA 会让任何持有其私钥的程序在你的机器上冒充任意 HTTPS 站点。
测试
三个层级:
npm test # offline unit/integration suite (synthetic pcaps, no privileges)
./scripts/e2e.sh --synthetic # same suite via the e2e driver (also no privileges)
./scripts/e2e.sh # REAL end-to-end (see below)合成管线测试(npm test,共 32 项:25 项管线 + 6 项来源/主机白名单 + 1 项工具链冒烟)
确定性、手工构造的 pcap/IP/TCP 夹具驱动完整的重组 + HTTP 重建管线:
- 简单 GET + 200 响应 → 一次完整交换
- 携带 JSON 主体的 POST 与 JSON echo 响应
- chunked 传输 + gzip 内容编码解码还原为原始 JSON
- 跨约 12 个分节的 64 KiB 响应按字节精确重组
- TCP 序号回绕(ISN 接近 2³²)
- 重传时每个字节恰好交付一次
- 乱序分节(1、3、2、4)按顺序组装
- 中途接入 → 部分交换,解析器重新同步到下一个请求
- FIN 拆除关闭双向;RST 中止并按部分交换终结
- DLT_NULL(环回)解码,兼容小端与大端 AF 头
- 未获准的流先缓存,获准后从流起点回放
- 带 VLAN 标签的以太网帧可解析;二进制主体以 base64 呈现
- pcapng 魔数与不支持的 DLT(含错误的 pktap 链路类型)产生清晰的错误
lsof输出解析(IPv4/IPv6、LISTEN 通配、已建立连接的对端)- pktap pid 标记解码(DLT_PKTAP 149):被包裹的 DLT_NULL 帧可解析,分节携带 pid + 接口
- pktap pid 准入——目标 pid 匹配则立即放行该流;不匹配则永不放行
- pktap 头部填充(156 字节的 xnu 结构)被跳过以抵达内层帧
- 被截断/畸形的 pktap 记录会被跳过,完好的记录仍可解析
- 半关闭:客户端在响应到达前发出 FIN,仍会得到一次完整交换
- close 定界的响应(无 Content-Length/chunked)在流结束(EOF)时完成
- 错误后的重同步恢复从干净的解析器状态开始(不会并入损坏的消息)
- 两侧同时重同步保持相互隔离(无跨方向残留污染)
真实端到端(./scripts/e2e.sh)
启动完整技术栈,拉起一个临时 HTTP 目标(test/e2e/server.js,端口 18888)和一个临时客户端进程(test/e2e/client.js),后者发出三个已知请求(一次 gzip JSON GET、一次 JSON echo POST、一次 64 KiB GET),并携带唯一的 X-Test-Marker 请求头。驱动程序对该客户端 pid 启动真实捕获——这会触发 macOS 管理员密码提示——等待后通过 API 断言三次交换均被正确重建(gzip 已解码、主体按字节精确、大小准确)。使用 --keep-service 可让服务在结束后继续运行。需要 GUI 会话(请在 Terminal.app 中运行)。
限制
- 仅支持明文 HTTP/1.x。 TLS 流量会显示锁形徽标及连接元数据(地址、端口、耗时、大小),但加密负载按设计保持不透明。HTTP/2 不会被重组。
- 接口:环回(
lo0)+ 默认活动接口(en0);其他接口可通过手动辅助命令捕获。pktap 限制(macOS):Apple 的 tcpdump(tcpdump-158)会为任何pktap*设备强制写入 PCAP-NG(open_capture: DLT_PKTAP ⇒ndo_Pflag++⇒pcap_ng_dump_open),而 local-spy 仅解析 classic pcap——因此--mode auto会直接选择 legacy,而不是去尝试一场永远无法就绪的捕获。完整的 pktap 机制(spawn、classic 魔数探测、管线中的 pid 准入、--mode pktap诊断路径)均已实现并保留:对于未来任何会写 classic pcap 的 tcpdump,它都会自动就绪(DLT_PKTAP 的链路类型号是 149——Apple 将其定义为 DLT_USER2;258 是上游的文件别名;278 是 OPENVIZSLA,并非 pktap)。 - 新连接的端口拾取 ≤ 约 1 秒:
lsof端口集每秒刷新一次;回放缓存意味着新开连接上的流量一旦获准就会被回放,而不是丢失。 - 主体上限 512 KiB(每条消息,超出即被截断并标记);环形存储 2000 条目(最旧的会被逐出)。
故障排查
| 症状 | 原因 → 解决办法 |
|---|---|
| BPF permission denied / 捕获启动失败,或管理员提示被取消 | 权限提升被拒绝。重试并批准 Apple 的对话框,或使用帮助页面上的手动回退命令(见授权与安全模型)。 |
| 捕获正在运行但表格始终为空 | 目标进程可能没有 TCP 流量,或者它是纯 TLS(设计上仅显示元数据)。请查看帮助页面;另请为新连接的准入留出约 1 秒。 |
| ELEVATION_UNAVAILABLE / 完全没有弹出提示 | 你处于无图形界面的会话(SSH)。请在 Mac 上真实的 Terminal.app 窗口中运行,或使用手动 sudo 回退命令。 |
| 7634 端口已被占用 | PORT=8080 ./scripts/run.sh(捕获过滤器会自动排除你选择的任何端口)。 |
| 崩溃后遗留 root tcpdump | 先用 pgrep -fl 'tcpdump.*\.run/pcap' 确认,再执行 sudo pkill -f 'tcpdump.*\.run/pcap'——该模式只会匹配 local-spy 的捕获。 |
| 按下 Capture 时出现 CAPTURE_LEFTOVER | 上一次捕获的 root 辅助脚本或 tcpdump 仍存活(崩溃遗留)——服务器拒绝在其之上启动新的捕获。运行 ./scripts/stop.sh(或按上述方法清除遗留进程),然后再次按下 Capture。 |
| node: command not found / engine 警告 / 启动时语法错误 | 需要 Node ≥ 22.18:用 node --version 检查,并从 nodejs.org 或 Homebrew 安装。 |
| run.sh 提示 pidfile pid is alive but health did not answer | pid 被复用,或服务器已卡死。删除 .run/pid/server.pid(前提是该 pid 不属于 local-spy),或先运行 ./scripts/stop.sh。 |
开发
npm 脚本
| 脚本 | 作用 |
|---|---|
| npm run build | tsc(服务器 → server/dist)+ Web 类型检查 + vite build(Web → web/dist) |
| npm run build:server / npm run build:web | 分别执行上述两半 |
| npm run typecheck:web | 仅对 Web 应用做类型检查(tsc -p web/tsconfig.json,不产出文件) |
| npm run dev:server | 服务器的 tsx watch 模式 |
| npm run dev:web | 面向 UI 的 Vite 开发服务器(热重载) |
| npm test | node --test test/*.test.ts(类型剥离,无需编译步骤) |
| npm start | 运行已构建的服务器(推荐使用 ./scripts/run.sh) |
Shell 脚本
| 脚本 | 作用 |
|---|---|
| ./scripts/run.sh | 预检 → 过期即构建 → 隔离启动 → 等待健康检查通过 |
| ./scripts/stop.sh | stopfile → 终止服务器 → 清理产物(仅在确认服务器 pid 已终止后)→ 遗留 tcpdump/capture-helper 报告 |
| ./scripts/e2e.sh | E2E 驱动(--synthetic、真实模式、--keep-service、--help) |
| scripts/capture-helper.sh | root 辅助脚本(由服务器运行,或经 sudo 手动运行——绝不会被上述脚本修改) |
端口
| 端口 | 使用者 |
|---|---|
| 7634 | local-spy 服务器(API + 已构建的 UI);可用 PORT 覆盖 |
| 7635 | HTTPS MITM 代理,仅在 LOCAL_SPY_PROXY=1 时启用(默认关闭) |
| 5173 | 开发期间的 Vite 开发服务器 |
| 18888 | 临时 E2E 目标服务器(test/e2e/server.js) |
项目结构
local-spy/
├── server/src/ # express + ws server
│ ├── index.ts # entry: http server, static UI, WS upgrade
│ ├── routes.ts # REST API
│ ├── store.ts # 2000-entry exchange ring
│ ├── processes.ts # ps/lsof process listing
│ ├── capture/ # manager, elevation, pcap tailer, TCP/HTTP pipeline
│ ├── proxy/ # opt-in MITM proxy: CA minting, CONNECT interception, recording
│ └── config.ts # ports, budgets, .run/ paths
├── web/ # React + MUI inspector UI (vite)
├── shared/protocol.ts # frozen wire contract shared by server + web
├── scripts/ # run.sh, stop.sh, e2e.sh, capture-helper.sh, trust-ca.sh, untrust-ca.sh
├── test/ # synthetic fixtures + pipeline tests
│ └── e2e/ # server.js, client.js, assert.js (real E2E)
└── .run/ # runtime state (pid/, logs/, pcap/, ca/) — gitignored许可证
MIT
