实测结论(macOS 27.0,HITL 确认):回环零交互;launchd 语境按二进制 归属,官方 sslocal(adhoc,linker-signed)主机地址态必弹裸名弹窗; DevID 重签静默放行为首选缓解;应答按路径落库、升级不重弹;拒绝后 本机自连不可见,需 --getappblocked 检测与定向修复文案。原型在 research/firewall-nwlistener @ 94829fe。
18 KiB
Wayfinder issue 16:非回环监听态与 macOS 应用防火墙的交互
- 票据:研究:非回环监听态与 macOS 应用防火墙的交互
- 父地图:Wayfinder: ShadowsocksX-NG 2.0 现代化重实现
- 研究日期:2026-09-20
- 适用范围:macOS 15+、仅 arm64、ShadowsocksX-NG 2.0;监听范围「主机地址」态(
0.0.0.0通配绑定,#5/#12)下,应用防火墙对 PAC 本地 HTTP 服务(#12/#13)与sslocal入站的提示、放行与拒绝行为。 - 证据规则:行为结论以本机实测为准,弹窗出现与否由用户实时目视确认(HITL);系统能力与默认语义以 Apple 一手文档与系统设置页原文为准;版本偏移(本机 macOS 27.0 Build 26A428,地图目标 macOS 15+)不作外推,进入第 10 节验证计划。
决策摘要
- 回环态(默认)零防火墙交互。 绑定
127.0.0.1的监听不触发弹窗、不产生防火墙条目(T1 实测)。2.0 的默认监听范围完全不受应用防火墙影响。 - 主机地址态下,官方
sslocal二进制必触发防火墙弹窗。 官方 v1.25.0 arm64sslocal实测为Signature=adhoc(flags=0x20002(adhoc,linker-signed),TeamIdentifier=not set);launchd 语境(per-user LaunchAgent,即 #13 确定的生产宿主形态)下,防火墙按二进制归属,ad-hoc 二进制首次非回环监听即弹出「你要应用程序"sslocal"接受传入网络连接吗?」——弹窗显示的是裸进程名,与 GUI 应用无任何视觉关联。 - 首选缓解:用 Developer ID 重签
sslocal(含 wrapper),使两者落入「自动允许已下载的签名软件」豁免,用户全程无弹窗。 实测:有效 CA 签名(Developer ID + Hardened Runtime)的二进制在本机默认配置(该开关开启)下静默放行(T4/L1);ad-hoc 即使加 Hardened Runtime 仍弹窗(R1c)。#2 已确立「嵌入 app 后的 helper 会被本项目重新签名」,本建议与其一致;流程上先校验官方 SHA-256 再重签即可保留供应链校验。该建议由 DevID 探针实测外推到 sslocal 本体,重签后的 sslocal 未单独实测(第 10 节)。 - 弹窗应答按路径落库,升级不重弹。 同一路径(app bundle 内路径稳定)替换为新哈希的二进制后不再弹窗(L6 实测:已允许路径换新哈希 → 静默;L4 的相反表象经用户指出混淆变量后修正——该路径的首个弹窗从未被应答,本无记录)。拒绝同理按路径静默维持(L5)。用户只需对每个固定路径应答一次。
- 防火墙拒绝不表现为本机自连失败——这是失败路径的主要盲区。 全部轮次中,同机连接自身 LAN IP 在弹窗未决、已允许、已拒绝三种状态下一律成功(本机流量不经防火墙执法面);Apple 文档明确「在您采取措施前,该消息会一直保留,且连接该应用的尝试都会被拒绝」指的是网络侧连接。因此「主机地址」态下,GUI 用
127.0.0.1/本机地址做健康检查无法发现防火墙拒绝,局域网侧实际已不可达。 - 拒绝的可检测性与修复路径存在且无需特权:
socketfilterfw --getappblocked <path>与--listapps在无 sudo 的普通用户态可读(本机实测),GUI 可据此给出定向修复指引(系统设置 → 网络 → 防火墙 → 选项)。 - 签名/公证的分工: 对防火墙起作用的是「有效证书颁发机构签名」身份(Developer ID);Hardened Runtime 本身不豁免 ad-hoc;公证属 Gatekeeper 首启域而非防火墙判定输入。本机无 notarytool 钥匙串配置,公证差异未实测(第 10 节)。
- GUI 宿主 vs LaunchAgent 宿主无行为差异:弹窗归属只与被归属二进制自身相关(L1 vs T4 一致);#13 的 LaunchAgent 宿主决策不引入额外防火墙负担。
1. 生产形态的监听面映射
监听范围两态(#12:回环默认,主机地址为显式 opt-in)同时约束两个监听面(#5):
| 监听面 | 宿主(#13) | 二进制来源与签名 | 回环态 | 主机地址态 |
|---|---|---|---|---|
| PAC 本地 HTTP 服务(NWListener 自研,#12) | per-user LaunchAgent BundleProgram(wrapper) | 本项目代码,DevID + Hardened + 公证(#15/#6) | 无防火墙交互 | 触发防火墙评估;DevID 下静默放行 |
sslocal 入站(SOCKS/HTTP) |
wrapper 以子进程监管 | 官方 Release 提取,ad-hoc、linker-signed(第 7 节实测) | 无防火墙交互 | 触发弹窗,点名 "sslocal";拒绝则局域网入站被拒 |
因此本票据的核心问题收窄为:主机地址态下,ad-hoc 的 sslocal 会被弹窗点名,以及 GUI 如何提示、检测与恢复。
2. 实测环境与方法
- 机器:macOS 27.0 Build 26A428,arm64,LAN IP
192.168.2.89(en0)。Xcode 27.0(27A266a),Swift 6.4。 - 防火墙初始状态(全程未改动):
--getglobalstate= 1(开启);block-all 关;「自动允许内建软件」开;「自动允许已下载的签名软件」开(系统设置原文:「允许由有效的证书颁发机构签名的软件提供可从网络访问的服务」);隐身模式关。无 passwordless sudo,未做状态翻转测试。 - 探针:单文件 Swift + Network.framework 最小
NWListener(TCP),参数化绑定(127.0.0.1/0.0.0.0/ 指定接口地址)、生命周期与--spawnbin子进程拉起;源码、LaunchAgent 样例与全部运行日志存于本地分支research/firewall-nwlistener@94829fe(prototype/)。 - 签名变体:ad-hoc(
codesign -f -s -)、ad-hoc +--options runtime、Developer ID Application: Qiu Yu Zhou (S878RH3PA8) +--options runtime。 - 启动语境:终端子进程(zsh);per-user LaunchAgent(
launchctl bootstrap gui/$(id -u)+kickstart)。 - 观测通道:
socketfilterfw --listapps/--getappblocked(实测均无需 sudo);同机nc -vz连接 LAN IP;弹窗由用户目视确认(截图两次、问答确认六次);防火墙守护进程在统一日志默认无输出(多谓词查询均空),不作为观测通道。 - 局限:① 同机→自身 LAN IP 的连接不经防火墙执法面,因此「弹窗未决/拒绝是否阻断真实局域网客户端」未获本机实测(Apple 文档语义 + 库存状态为间接证据,第 10 节列入复测);② macOS 15 未验证;③ 未应答弹窗对真实远端的拦截行为无法用同机连接区分。
3. 实测矩阵
| 轮次 | 签名 | 语境 | 绑定 | 弹窗 | 应答 | 库存结果 | 同机连接 |
|---|---|---|---|---|---|---|---|
| T1 | ad-hoc | 终端 | 127.0.0.1 | 无 | — | 无条目 | 成功 |
| T2 | ad-hoc | 终端 | 0.0.0.0 | 有(点名 fwprobe,悬置约 2 分钟) | 允许 | Allow(路径落库) | 未决期间成功 |
| T3/R1b/R1c | ad-hoc+hardened | 终端 | 0.0.0.0 | 无 | — | 无条目 | 成功 |
| RunA | ad-hoc 新哈希新路径 | 终端 | 0.0.0.0 | 无 | — | 无条目 | 成功 |
| T4 | DevID+hardened | 终端 | 0.0.0.0 | 无 | — | 无条目 | 成功 |
| L1 | DevID+hardened | LaunchAgent | 0.0.0.0 | 无 | — | 无条目 | 成功 |
| L2 | ad-hoc(同 RunA 二进制) | LaunchAgent | 0.0.0.0 | 有(点名 fwprobe-v2) | 允许 | Allow | 成功 |
| L3 | DevID 父 + ad-hoc 子(fwprobe-v3) | LaunchAgent | 0.0.0.0 ×2 | 有,点名子进程 fwprobe-v3,父静默 | 未应答 | 暂无 v3 条目 | 成功 |
| L4 | fwprobe-v3 路径换新哈希(此前弹窗未应答) | LaunchAgent | 0.0.0.0 | 有(点名 fwprobe-v3) | 允许 | Allow | 成功 |
| L5 | ad-hoc 新路径(fwprobe-v5) | LaunchAgent | 0.0.0.0 | 有 | 拒绝 | Block;重启同二进制静默维持 | 成功(拒绝后仍成功) |
| L6 | 已允许路径(fwprobe-v2)换新哈希 | LaunchAgent | 0.0.0.0 | 无 | — | 原 Allow 维持 | 成功 |
全部日志见 research/firewall-nwlistener @ 94829fe prototype/logs/。
4. 发现一:回环豁免与「评估触发面」
T1(回环)零交互,所有非回环轮次(0.0.0.0、LAN IP)都触发评估或弹窗。结论:防火墙的触发条件是监听地址超出回环,与端口无关。对 2.0 的直接推论:默认回环态下防火墙维度可完全忽略;主机地址态是唯一需要 UI 与失败路径设计的状态——与 #12 把非回环定为显式 opt-in 的决策互相印证。
5. 发现二:弹窗的形态与时序
- 弹窗文案(中文系统):「你要应用程序"<二进制名>"接受传入网络连接吗?点按"拒绝"可能会限制应用程序的行为。此设置可以在"网络"设置的"防火墙"面板中更改。」+「拒绝/允许」按钮。显示名是被归属二进制的裸文件名——T2 显示
fwprobe,L3 显示子进程fwprobe-v3。生产环境中用户将看到的是sslocal(或重签后的 wrapper 文件名),与"ShadowsocksX-NG"名字无关。 - 弹窗出现有秒级异步延迟(监听后数秒内),且未应答时长期悬置(T2 悬置约 2 分钟直到用户应答;Apple 文档:「在您采取措施前,该消息会一直保留,且连接该应用的尝试都会被拒绝」)。
- 弹窗出现后不对本机连接产生可见影响(全部轮次同机连接成功,见第 6 节盲区)。
6. 发现三:归属模型与「本机执法盲区」
归属(谁被点名/记录): launchd 语境下按二进制逐个归属——DevID 父进程(wrapper 形态)拉起的 ad-hoc 子进程触发的是子进程自己的弹窗(L3),父进程的 Developer ID 签名不覆盖子进程。GUI 宿主与 LaunchAgent 宿主行为一致(T4 vs L1),#13 的宿主决策不改变防火墙语义。
执法(连接是否被断): 同机→自身 LAN IP 的连接在弹窗未决(T2/L2/L3/L4)、已允许(T2 后)、**已拒绝(L5 拒绝后,库存已确认 Block)**三种状态下全部成功。即本机流量不经防火墙执法面。Apple 文档所述「未应答期间连接被拒」只对网络侧客户端生效(本次未获真实远端实测,第 10 节)。
终端语境异常(非生产形态,仅记录): T2 应答一次后,同谱系(同一责任进程链)的后续全新 ad-hoc 二进制(T3/R1c/RunA)在终端语境下全部静默;同一 RunA 二进制切到 LaunchAgent 语境立即弹窗(L2)。解释为防火墙按「责任进程」放行了终端宿主应用的谱系。该现象解释了为什么开发期从终端起服务「从不弹窗」,也说明不能用开发期体感推断生产行为——生产判断只采信 launchd 语境数据。
7. 发现四:官方 sslocal 的签名态(弹窗必然性的根据)
官方 v1.25.0 arm64 归档(SHA-256 本地复验一致:58e0caf0…55208)提取的 sslocal:
Executable=/private/tmp/fwresearch/sslocal
Identifier=sslocal-f075032ba57bee22
Format=Mach-O thin (arm64)
CodeDirectory v=20400 size=73048 flags=0x20002(adhoc,linker-signed) hashes=2279+0 location=embedded
Signature=adhoc
TeamIdentifier=not set
即上游发布物由 Rust 链接器 ad-hoc 签名,无 CA 身份。结合 L2/L3/L5:主机地址态下 sslocal 首次监听必然触发点名 "sslocal" 的弹窗;且因 #2 规定每次跟随官方 Release 更新二进制,若按官方字节原样分发,每次更新前对路径的旧记录仍然有效(L6:按路径落库,升级不重弹)。
8. 发现五:应答落库按路径键,升级不重弹;拒绝静默维持
- L6(决定性):已允许路径
fwprobe-v2(L2 应答「允许」)的内容整体替换为新哈希(fwprobe-v6)后,重新非回环监听未弹窗,原 Allow 记录继续生效。 - L4 的修正过程:L4 曾显示「同路径换新哈希后再次弹窗」,初读为哈希键;经用户指出 L3 的首次弹窗从未被应答(该路径本无记录),L4 的弹窗是路径首次记录而非重弹。L6 在干净前提(确认允许的路径)上复测定案:记录按路径键。
- L5(拒绝路径):拒绝后
--getappblocked返回 blocked、--listapps显示「屏蔽传入连接」;重启同二进制不再弹窗、静默维持拒绝;恢复只能由用户在系统设置 → 网络 → 防火墙 → 选项中手工改回(或删除条目后重新弹窗授权)。 - 生产含义:app 内固定路径(
Contents/Helpers/sslocal等)意味着每个路径只需用户应答一次,跟随版本更新不会反复打扰;但一次误点「拒绝」会让主机地址态静默失效,必须由 GUI 检测并给修复指引(第 9 节)。
9. 对 2.0 的建议
9.1 首选:Developer ID 重签 sslocal,把弹窗从用户路径上消掉
- 依据:DevID + Hardened Runtime 二进制在本机默认配置(「自动允许已下载的签名软件」开)下静默放行(T4/L1);ad-hoc 必弹窗(L2/L3/L5)。
- 流程(与 #2/#6 已确立的「官方构建产物 + 嵌入后重新签名」一致):发布脚本按固定 SHA-256 校验官方归档 → 提取
sslocal→codesign -f -s "Developer ID Application: …" --options runtime --prefix <identifier> sslocal→ 装入Contents/Helpers。供应链校验在重签前完成,不被破坏。 - 效果:主机地址态全程无弹窗(前提是用户未关闭 allowsigned 开关);wrapper(PAC 服务)同理已是 DevID,静默放行。
- 未竟项:重签后的 sslocal 本体未实测;macOS 15 上 allowsigned 的判定口径需复测(第 10 节)。
- 备选(不重签):接受首次弹窗 + UI 预告知(9.2/9.3),升级不重弹(路径键),误拒绝靠 9.4 检测恢复。
9.2 被点名名称的用户可读性
弹窗显示的是二进制裸文件名。若按 9.1 重签 sslocal,主机地址态无弹窗,名称问题消失;若采纳备选方案,用户会看到一个陌生的 "sslocal" 弹窗——切换 UI 必须提前预告这个名字,否则极易被当成恶意进程点「拒绝」。wrapper 的 BundleProgram 文件名也应取用户可读名(如含 "ShadowsocksX-NG" 字样),使 allowsigned 关闭用户的弹窗可识别。
9.3 监听范围切换 UI 的提示文案(草稿)
切换到「主机地址」时的确认/提示文案:
主机地址模式会把代理入站与 PAC 端点暴露给局域网设备。 首次启用时,macOS 可能弹出防火墙确认:
- 若弹窗询问"sslocal"(或"ShadowsocksX-NG Helper"),请选择「允许」;
- 误点「拒绝」会导致局域网设备无法连接,可在 系统设置 → 网络 → 防火墙 → 选项 中改正。
本模式仅建议在可信局域网使用。
(采用 9.1 后,第二条弹窗预告可视复测结果弱化为脚注;「暴露给局域网」的警示语保留——它来自 #12 §7 的既定约束,与防火墙无关。)
9.4 失败路径:检测、呈现与恢复
- 检测:
socketfilterfw --getappblocked <path>与--listapps无需 sudo(本机实测);GUI 可对sslocal与 wrapper 路径轮询/切换时查询,blocked 态即在设置页显示诊断项「macOS 防火墙已阻止代理接受连接」。CLI 属非公开工具,调用需容错(命令缺失/行为变化时静默降级为通用提示)。 - 呈现:诊断分类在 #12 §5 的端口冲突/监听失败之外新增「防火墙被拒」;代理整体状态不得因此显示为"正常"(局域网目标实际不可达),建议呈现为「等待授权/部分可用」中间态。
- 修复指引文案:「macOS 防火墙已阻止 sslocal 接受传入连接。前往 系统设置 → 网络 → 防火墙 → 选项,将 sslocal 设为"允许传入连接";或在此处移除该条目后重启代理以重新触发授权。」
- 健康检查盲区:本机自连(127.0.0.1 或本机 LAN IP)不受防火墙执法(第 6 节),主机地址态的可达性自检不能只对本机发起;诊断页应引导用户「从局域网内另一台设备访问」做端到端验证(真实远端探测不在 GUI 内自动化的理由:无受控远端探测点)。
10. 验证计划(进入实现前完成,不把本报告推断当已验证)
- macOS 15 复测:全矩阵(回环/通配 × ad-hoc/DevID × LaunchAgent;弹窗文案、归属、路径键、allowsigned 豁免、
--getappblocked可用性)。本报告全部数据来自 macOS 27.0。 - 重签 sslocal 实测:按 9.1 流程重签官方 v1.25.0 sslocal,LaunchAgent 语境主机地址态监听,确认静默放行;同时验证重签后 Hardened Runtime 下 sslocal 功能无损。
- allowsigned = off 行为:关闭「自动允许已下载的签名软件」后 DevID 二进制是否弹窗(预期弹一次、路径键记忆)——需在系统设置人工切换后复测。
- 真实远端执法面:从局域网另一设备实测弹窗未决与拒绝态下的可达性(验证 Apple 文档「未应答即拒绝」在本机版本的表现与拒绝的具体失败形态:超时还是拒连)。
- 公证差异:在有 notarytool 凭据的环境验证公证与否是否影响防火墙(预期不影响,公证属 Gatekeeper 域)。
- 升级路径回归:模拟 sslocal 版本更新(同路径新哈希、DevID 重签)确认不重弹(L6 的生产版复核)。
11. 对后续票据的明确约束
- #5(UI 信息架构):监听范围切换的确认文案按 9.3 执行;设置/诊断页新增「防火墙被拒」状态与修复指引(9.4);主机地址态的代理状态呈现需包含「等待授权/部分可用」中间态。
- #2/#6(打包与发布):发布脚本在官方 SHA-256 校验后、装入 Helpers 前插入 DevID 重签步骤(9.1);打包清单加入「重签后
codesign -dv抽检 + 防火墙静默放行断言」(对应 #15 追加的门槛断言风格)。 - #12(PAC 服务实现):§6.2「记录 socketfilterfw 对入站监听的提示/放行行为」由本报告完成;实现票据在失败语义中纳入
--getappblocked检测(9.4),并保持回环默认的两态设计不变。 - #13(宿主):wrapper 的 BundleProgram 文件名取用户可读名(9.2);宿主形态不引入额外防火墙负担(第 6 节),维持既有决策。
- 地图 #1:第 10 节验证计划为 macOS 15 门槛的一部分,进入实现阶段前复核。
本报告只新增研究文档与本地原型分支;未修改 Legacy/ 或 2.0 生产代码。