Skip to content

feat: 仅在必要时提权,用 root helper 替代整进程 sudo - #7

Merged
zcf0508 merged 35 commits into
mainfrom
feat/privileged-helper
Oct 23, 2026
Merged

zcf0508 merged 35 commits into
mainfrom
feat/privileged-helper

Conversation

@zcf0508

@zcf0508 zcf0508 commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Supersedes #4.

背景

#4 的方向是对的(只在必要时提权),但实现上有三处硬伤,这也是它一直停留在 WIP 的原因:

  • exec 启动 Caddy 会永久阻塞,且 stdout/stderr 监听被注释掉,健康检查与 Caddyfile 语法错误都发现不了
  • testCaddy() 去掉 sudo 后永远返回 false,导致每次启动都重新下载 Caddy
  • runAsAdmin reject 的是 boolean,调用方 if (!await addHost(...)) 直接抛异常逃逸

本 PR 按同样的目标重做了一版。

改了什么

主进程不再需要管理员权限。 只有 helper 子进程以 root 运行,Caddy 的二进制和 Caddyfile 在启动时固定,socket 请求只能表达“何时做”,不能表达“做什么”。

之前 现在
sudo -E npm run dev 跑整个 dev server 用户直接 npm run dev,插件自己提权
node_modules、.vite、pnpm store 被 root 写坏 全部保持用户身份
npx @maxbo/free-port 以 root 写 ~/.npm helper 内按 pid 释放 80/443
chmod 777 /etc/hosts 且只改不还 不再改动系统文件权限
四处 isAdmin() 闸门 已移除

关键设计

  • 一次认证:启动时 sudo -v 提示一次密码,之后全部 sudo -n
  • 异常退出也能清理:清理依赖 socket 断开而非进程的 exit hook,主进程被 SIGKILL 也能恢复 hosts、停掉 Caddy
  • 崩溃兜底:helper 启动时、接受第一个客户端之前先清理上次遗留的条目
  • 输入校验:hostname 与 IP 都校验,拒绝换行,防止向 hosts 注入不受管理的行
  • 保留用户数据:127.0.0.1 a.test b.test 添加 a.test 时保留 b.test

验证

  • tsc --noEmit、eslint 通过
  • 17 个测试文件 73 个用例通过
  • 真实端到端(macOS):helper 以 root 拉起 → 写 hosts → Caddy 监听 443 → caddy trust → curl 不带 -k 返回 http=200 tls_verify=0 → 断开后 hosts 自动还原
  • 主进程 SIGKILL 后 helper 仍自动恢复 hosts
  • 消融验证:移除输入校验、端口白名单、启动清理、teardown 代次检查,对应测试均失败

已知限制

  • Windows 路径(命名管道 + UAC)未在实机验证
  • :7601(暴露 PKI 目录供手机下载证书)与 :7600(正向代理)是既有行为,本次未改动

协议编解码、输入校验、hosts 文件编辑与端口 pid 解析,均为纯函数,
不触碰系统状态,便于单元测试。

- protocol: newline-JSON 协议,helper 与客户端共用
- validation: 校验 hostname 与 IP,拒绝换行与注释符,防止 hosts 注入
- hosts-file: 按 token 剔除映射,保留同一行上的其他名字
- ports: lsof / netstat 输出解析
helper 以 root 运行但只做原语操作,Caddy 的二进制与配置在启动时固定,
socket 请求只能表达"何时做",不能表达"做什么",避免变成任意 root 执行。

- server: 请求路由,hosts 编辑、端口释放、Caddy 启停、CA 安装
- bootstrap: socket 监听、token 握手、socket 属主交还发起者
- client: 带重连的 RPC 客户端,权限错误不静默重试
- launch: 一次 sudo -v 预认证 + sudo -n 启动,不经 detached 以免丢失凭证
- session: 同一运行目录复用 helper,第二个 dev server 不再要密码

主进程退出或崩溃时 socket 断开,helper 自行恢复 hosts 并停止 Caddy;
helper 启动时先清理上次崩溃遗留的条目。
原先要求用户执行 sudo -E npm run dev,导致 node_modules、.vite 与 pnpm
store 全被 root 写坏。现在主进程保持用户身份,只有 helper 子进程提权。

- CaddyInstant 经 helper 启停 Caddy,会话断开后自动重连
- caddy.start 失败时回滚已写入的 hosts 条目
- 移除 isAdmin 闸门,不再要求整个 dev server 以管理员运行
- 移除 npx @maxbo/free-port,改为 helper 内按 pid 释放 80/443
- 移除对 /etc/hosts 的 chmod 777,不再改动系统文件权限
- Caddyfile 增加 skip_install_trust,本地 CA 由 helper 显式安装
- hosts 编辑、端口解析、协议编解码的单元测试
- helper 运行时的原语路由、断连清理、代次隔离
- socket 握手、token 校验、崩溃遗留清理的集成测试
- CaddyInstant 通过伪造会话验证调用序列与失败回滚
- 移除 hostile、@types/hostile、@types/kill-port
Windows 没有 sudo:提权走 Start-Process -Verb RunAs,helper 通过命名管道
与主进程通信,因此不需要继承 stdio。PowerShell 参数按单引号规则转义,
路径含空格或撇号时不会被拆开。
用户直接 npm run dev 即可,插件会在需要时提示一次管理员密码。
补充 NOPASSWD 白名单的可选配置与 Windows 的 UAC 说明。
原先假设 helper 与调用方同目录,并从 moduleDir 下取 ../node_modules。
实际上调用方在 src/caddy、src/privileged 这样的子目录里,helper 在上一级
src,tsx 装在项目根的 node_modules,源码模式下两者都找不到,直接抛出
"privileged helper entry not found"。

改为从 moduleDir 逐级向上查找,到文件系统根为止。dist 下的构建产物仍优先
命中同目录的 privileged-helper.js。
原先 sudo -v 会在 npm run dev 中途突然弹出密码提示,用户不知道为什么要
输;取消密码又会让 Promise reject 逃逸成 unhandled rejection,整个 dev
server 跟着崩掉。

现在提权前先打印提示原因:绑哪个端口、改哪条 hosts、只问这一次、Ctrl+C
可以跳过。取消密码识别为 PrivilegeDeniedError,各入口降级为 warn 并继续
以普通 HTTP 运行。

拒绝状态记在模块作用域里:Vite 热重启是 Object.assign(server, newServer)
原地重建,标记能跨重启保留,保存配置不会再次索要密码;重启整个 dev server
才会重置。失败的实例同时置空,避免残留半初始化的状态。
拒绝状态跨热重启保留、重复读取仍为拒绝;引导文案按 https 与 http 分别给出
端口号,并说明只问一次与 Ctrl+C 可跳过。

消融验证:让 isPrivilegeDenied 恒返回 false 时,2 个用例失败。
多个 dev server 共用一个 helper,它启动的 Caddy 属于所有项目。原先的行为
会把这份共享资源当成私有:

- 启动参数没有 --watch,第二个项目写入 Caddyfile 后不会生效,路由永远到
  不了 Caddy(重构时从旧实现漏掉的);
- caddy.start 先停再起,第二个项目会把第一个项目的代理重启掉,现在改成
  幂等:已在运行就返回现有 pid;
- port.free 只跳过 helper 自身 pid,会把另一个项目的 Caddy 杀掉,改为由
  调用侧传入要保留的 pid;
- hosts 映射不区分项目,先退出的项目会删掉别人也在用的域名。改成按连接
  引用计数,最后一个持有者离开时才删除。

同时记录 Caddy 子进程的 running 状态,崩溃后再次 start 会真正重新拉起。
- Caddyfile 与路由 JSON 是全局共享的。原先每个项目都用自己的 signal 缓存
  读改写,两个项目几乎同时启动时后者会覆盖前者的条目;Caddyfile 也从过期
  视图重新生成。现改为在文件锁内重新读磁盘,用临时文件 rename 落盘,并在
  锁外按 pid 清理已经退出的项目的条目。

- 退出清理原先会停 Caddy、删 Caddyfile、删锁、删 JSON。多个项目并存时,
  先退出的那个会把仍在运行的项目的代理一起关掉。现在只删除自己的路由,
  Caddy 由 helper 在最后一个客户端断开时停止。

- 随之去掉 caddylock 选主:它只能表达"谁负责启动",无法表达"谁还活着",
  而且判定与实际 lockSync 之间存在竞争窗口。

- 生成的配置里记录 ownerPid,崩溃的 dev server 留下的路由会在下次读写时
  被清掉,避免域名指向不存在的上游。
两个项目同时启动时都看不到 socket,于是各自拉起一个 helper;后起的那个会
rmSync 掉前一个的 socket,前一个变成孤儿继续占着 443。用一把独立的启动锁
包住"检查 socket → 复用 → 启动"这段窗口。

锁文件与 socket 分开:socket 只在 helper 监听后才存在,而这里要保护的是它
出现之前的那段时间。
原先 helper 只在收到 SIGTERM/SIGHUP 时退出,客户端断开后只是 dispose(清理
hosts、停 Caddy),进程本身一直挂着。反复重启 dev server 会累积 root 进程,
而且闲置的 helper 让同用户的其他进程无需密码就能拿到特权原语。

最后一个连接断开后启动计时器,默认 30 秒无客户端则退出。同一终端内重启通常
快于这个间隔,仍会复用现有 helper,不会重新输密码。

退出时只删除仍属于自己的 socket 与 token:更新的 helper 可能已经接管它们。
`sudo -v` 继承了终端,Ctrl+C 发出的 SIGINT 会同时到达 dev server。密码提示
发生在注册清理钩子之前,进程没有任何 SIGINT 处理器,默认动作直接终止,用户
看到的是"按 Ctrl+C 跳过反代,结果整个 dev server 退出",退出码 129。

现在提示期间临时接管 SIGINT,只把信号记为"取消",提示结束后立刻卸载,避免
吞掉用户之后真正想停掉 dev server 的 Ctrl+C。

顺带把 authenticateSudo 改成异步:spawnSync 会阻塞事件循环,处理器没有机会
执行,取消会被误报成普通失败。
第一个项目启动 Caddy 后会打印 serving initial configuration,插件据此判断就绪。
第二个项目复用的是同一个 Caddy,这行日志早已输出过,不会再出现,于是 run()
的 Promise 永远不 resolve:既不打印成功提示,也不显示代理地址。

helper 现在会返回 started: false 表示"已复用",插件据此直接进入就绪流程。
main 分支原先靠 caddylock 选主进入 else 分支来做到这一点,重构去掉选主后
漏了这段,这里补回同样的语义,但依据从"锁被占用"改为"helper 明确告知已复用"。
Vite 为快捷键把 stdin 设为 raw 模式。raw 模式下终端不做行编辑,回车不再
终止 sudo 正在读取的那一行,表现为输完密码还要再按一次回车才提交。

提示开始前切回规范模式,提示结束(成功、失败、Ctrl+C、命令不存在)后还原
之前的 raw 状态,Vite 的快捷键不受影响。

同时新增 quiet 选项:测试没有终端,不应把密码提示打散在测试输出里。
空闲退出直接调用 process.exit,生产环境里 helper 是独立进程,这是对的。
但测试在同进程里调用 startHelper,这行会把 vitest 的 worker 一起终止,
导致其余测试没跑完、Duration 一直增长。

新增 exitOnIdle:默认 true,测试传 false 时只关闭监听并释放 socket。
reverse-proxy 下的 5 个用例要改 hosts 并绑定 80/443,没有提权时每个都失败,
且各自等满超时才结束,一次运行要拖一百多秒。

改为用 `sudo -n true` 探测权限,拿不到就整体跳过。同样的行为已由注入假会话
的用例覆盖,这个文件保留用于捕获适配层回归。
上一版只把终端切回规范模式,仍解决不了密码读不到的问题。Vite 的快捷键
同时挂了一个 data 监听器并 resume 了 stdin,用户输入的字符先被它消费,
sudo 读到空内容,三次后报 3 incorrect password attempt。

提示开始前 pause stdin:流不再从 tty 读,字符留给 sudo。pause 不移除监听器,
提示结束 resume 后 Vite 的快捷键照常工作。
多个项目共用一个 Caddy,而 Ctrl+C 会把 SIGINT 发给整个前台进程组。Caddy 由
helper 启动且未 detached,继承了这个进程组,于是一个项目退出就把所有人共用的
Caddy 一起关掉——另一个项目的域名随即 refused to connect。

spawn Caddy 时加 detached,在 POSIX 上即 setsid,自成会话和进程组。Windows
没有进程组可脱离,detached 反而会另开控制台窗口,所以那边不开。

Caddy 脱离后不再随 helper 退出,另加 process.on('exit') 同步 SIGTERM 兜底。
SIGKILL 无法捕获,那种情况由下次启动的 port.free 清理。
helper 和启动它的 dev server 同进程组,原本 SIGTERM 和 SIGHUP 都会直接走
dispose()——无差别清掉所有 hosts 并停 Caddy,不管还有没有别的项目连着。
关掉一个终端窗口,另一个项目就断。

改为终端信号一律忽略:SIGINT、SIGQUIT、SIGTSTP、SIGHUP。忽略 SIGTSTP 还
顺带避免了 Ctrl+Z 把 helper 挂起、让所有已连接项目的 RPC 卡住。

SIGTERM 单独处理:它来自 IDE 或进程树结束工具,是真正要收工的意图,但同样
不能踢掉存活会话,所以只在没有客户端连接时才 dispose。
removeProxy 只改路由 JSON,不重新生成 Caddyfile,于是退出项目的域名一直留在
里面,被 Caddy 反代到一个已关闭的上游。

把渲染下沉到配置锁内:writeCaddyConfig 和 removeProxy 改完路由就在同一把锁
里渲染。放在锁外会让两个并发写入各自用自己那份过期视图渲染,后写的覆盖新的。

顺带的好处是 readConfig 会过滤掉 owner 已退出的条目,每次修改都重新生成,
被 SIGKILL 的项目留下的路由也就跟着清掉了。
上一版把渲染放进了配置锁,但还有三个窗口没关上:

- run() 在 writeCaddyConfig 之后又调了一次 updateCaddyfile,它在锁外读路由
  再写 Caddyfile。A 读到 {A},B 在锁内写入 {A,B} 并渲染,A 再用 {A} 覆盖,
  B 的站点就消失了。删掉这次调用,渲染只由锁内的那一次负责。
- removeProxy 在释放锁之后才删配置文件。中间另一个项目写入自己的路由,随后
  的 unlink 会把它删掉。改成在锁内写空对象,不再 unlink。
- 渲染直接 writeFile,Caddy 的 --watch 可能读到写了一半的文件并解析失败,
  启动者的 stderr 处理会据此退出 dev server。改成临时文件加 rename。

顺带删掉两个没有调用方的东西:updateCaddyfile(已被锁内渲染取代)和
stopCaddy(它会杀掉所有项目共用的 Caddy,留着容易被误用)。

配置路径改成可注入,测试各自拥有独立文件,不再并行写同一份而互相干扰。
协议层只暴露固定原语,但还有几条路能绕过它:

- Caddy 以 root 运行,admin API 却监听 127.0.0.1:2019,本机任何用户 POST
  /load 就能重新配置它,完全不经过 socket 和 token。改成 unix socket,放在
  运行时目录里,只有能进入该目录的人才能连。ca.trust 的 --address 跟着走。
- Caddy 二进制和 Caddyfile 的路径由用户进程传入,helper 却以 root 使用它们。
  启动时校验:不能是符号链接(可被重新指向)、属主必须是 root 或发起者、
  不能 group/world 可写。不满足就拒绝启动。
- Linux 上 TMPDIR 通常未设置,原来回退到 /tmp。那是 1777,其他用户可以先放
  一个 /tmp/caddy,之后由 helper 以 root 执行。回退值改成用户自己的缓存目录。

路径校验放独立模块,便于单测覆盖各种拒绝条件;startHelper 的集成测试验证
拒绝确实发生在启动阶段。
@zcf0508
zcf0508 merged commit 7ebf3ec into main Oct 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant