20 KiB
iOS 双通道方案:内网直通 + 中继到节点 agent
状态:设计稿(未实施)。目标是让 iPhone 在不出 VPN、不做内网穿透映射的前提下管理内网节点, 同时保留现有局域网直通能力。
1. 约束与结论
三条约束里只能同时满足两条,这是本方案的全部出发点:
- 手机不依赖 VPN 客户端。
- 内网不存在任何公网可解析的入口(端口映射、DDNS、frp/ngrok/Cloudflare Tunnel 的公网 hostname 都算,判定标准是"打到这个名字能不能进内网",与连接建立方向无关)。
- 中继服务器不参与信任。
因此拓扑必须是:手机 → 公网中继 → 节点 agent,其中 agent 只发起出站连接,且中继看不懂 也改不动流经它的指令。第 3 条不是靠"尽量安全"实现的,而是靠逐条签名 + 逐条加密实现的; 少了密码学,这套东西就只是一个更难用的 VPN。
iPhone ──HTTPS(签名信封)──> 中继(VPS, 哑投递) <──出站 HTTPS 长轮询── 节点A agent ──HTTP──> 127.0.0.1:3000
每节点一条 agent 连接,agent 只被允许访问本机回环
<── 表示连接由 agent 侧发起,内网无任何监听端口。
实现修订(2026-09):agent↔中继采用长轮询 HTTPS而非 WSS。理由是实现约束而非协议偏好: agent 必须只用 Go 标准库交叉编译(WebSocket 的 framing/masking/ping-pong/close 要几百行且需要 自己测试),而"连接由内网侧发起、内网无监听"这一安全性质与承载协议无关。握手为两段式 challenge-response(
/v1/agent/hello→ 签服务端随机数),投递为/v1/agent/poll(25s 挂起)
/v1/agent/result,代码在simadmin-relay/agent/internal/transport/与relay/src/。
2. 为什么是"每节点一个 agent"而不是"内网一个网关 agent"
用户提出的这个选择是正确的,且理由比运维便利性更强:它把 SSRF 跳板的爆炸半径从整个内网收缩到 一台本来就已经物理接触的节点。
网关型 agent 必须能访问 192.168.1.0/24,于是一条合法签名的指令可以拿它当跳板去扫内网任意
主机任意端口(http://192.168.1.1:80/admin、http://169.254.169.254/...)。要堵住就得再加
一份 origin 白名单,而白名单本身又是一个需要人维护、容易配错的安全边界。
每节点 agent 则可以把出口硬编码为回环:agent 启动时校验目标 host 必须是 127.0.0.1 或
::1,否则拒绝启动。这条规则不需要配置、不需要白名单、不可能配错。
代价是 agent 数量 = 节点数量,每台都要装、都要配对、都要保活。以及:如果某台节点上的 SimAdmin 并不在回环上(比如跑在另一台机器上),本方案对它不适用,需要退回网关型并显式配置 CIDR 白名单。
3. 密码学信封
密钥(每设备一套,每节点一套):
| 密钥 | 算法 | 用途 |
|---|---|---|
K_app_sign |
Ed25519 | 手机签名请求信封,agent 验签 |
K_app_kx |
X25519(静态) | 手机接收响应时的 DH 一方 |
K_agent_sign |
Ed25519 | agent 签名响应,手机验签 |
K_agent_kx |
X25519(静态) | agent 接收请求时的 DH 一方 |
请求信封(手机 → 中继 → agent):
{
"v": 1,
"node": "nd_7f3a...",
"req": "<uuid4>",
"app": "ap_5b1d...",
"ts": 1727289600,
"alg": "X25519-ED25519-AES256GCM-HKDFSHA256",
"ek": "<base64 32B>", // 本次请求的临时 X25519 公钥
"p": "<base64 AES-256-GCM>",
"s": "<base64 Ed25519>"
}
短字段名是刻意的:这是三语言共享的线上格式,Go / Node / Swift 各自的结构体标签必须逐字一致,
而长名在手写解码器里最容易抄错。字段名以 agent/internal/wire/wire.go 为权威。
s 覆盖 v,node,req,app,ts,ek,p 的规范化字节串(固定字段序 + 每个字段 uint32be 长度前缀,
不做 JSON 字符串拼接签名)。这一点是实质性的:签名包含 node,中继就无法把一个对节点 A 有效
的请求改投给节点 B;签名包含 req 与 ek,就无法做响应错配或临时公钥替换;签名包含 app,
中继就无法把一台手机的流量改标成另一台的。响应信封用另一个域字符串 msa-relay-v1-resp 且
不含 app,因此两个方向的签名不能互换。
规范化字节串的确切定义(含 v/ts 也带 00000008 长度前缀这一条)以
contracts/test-vectors.json 为准,该文件由 agent/cmd/msa-vectors 生成,并由 Node
(test/wire.test.js)与 Swift 各自重算。三语言互不共享代码,因此它们的字节一致是真实证据,
不一致则说明编码漂移,而不是"某一端坏了"。
alg 只有唯一合法值,且在任何其他字段之前校验。未知值一律硬错误,不做算法协商——降级攻击
就发生在协商里。
payload 解密后的内层:
{ "m": "POST", "p": "/api/sms/send",
"intent": "<base64 sha256(upper(trim(m))|p|canonicalJSON(body))>",
"body": "<base64 原始请求体>" }
body 以原始字节透传,agent 不解析也不重新序列化:一旦重新编码 JSON,intent 所覆盖的字节就
和真正转发上去的字节不同,确认语义随之失效。p 携带完整路径与查询串,agent 不做路径拼接,
因此不存在来自中继的路径穿越面。
ts 由信封层统一负责新鲜度(|now - ts| > clockSkewSeconds 即拒),内层不再带 deadline
字段:两套过期语义只会互相冲突,而且信封层的那一个受签名保护,内层的那一个不受。
密钥派生:请求方向 HKDF(X25519(appEphPriv, K_agent_kx.pub), salt=nodeID, info="msa-relay-v1-req"),
agent 侧用 X25519(K_agent_kx.priv, ek) 得到同一密钥;响应方向由 agent 每次新生成临时密钥对,
把公钥放在响应信封的 ek 里(同样被签名覆盖),与手机本次请求的 ek 做 DH——即响应方向是
临时×临时,手机不需要在配对后保留可用的 K_app_kx 私钥于响应路径(第 11 节第 5 项的缓存方案
因此已由实现取代)。
这里的前向安全性质要说准,不能含糊。 请求方向是"临时-静态"单消息 DH(HPKE base 模式的 同一形状),因此只对发送方(手机)的长期密钥泄露是前向安全的,对接收方(agent)的不是; 响应方向(实现后)双向临时,任一方长期密钥泄露都不能解密历史响应:
- 请求方向的
ek以明文出现在被签名的信封里。日后K_agent_kx.priv泄露的人,可以用它和录到的ek直接算出历史请求的密钥,从而读出历史请求内容。 - 反过来,
appEphPriv与agentEphPriv用后即弃且从不上传线,所以只泄露"发送方"的长期密钥 不足以解密历史。
实现修订:响应方向已改为对手机本次请求的临时
ek加密,无需额外往返,因此上段"建议的 缓存方案"对响应方向已无必要。残留的弱化只剩请求方向:要让请求也对K_agent_kx泄露前向安全, 才需要手机缓存 agent 的当前临时公钥(bootstrap 第一条除外)。是否值得做,留待 P4 后再定。
GCM IV 取 HKDF(S_req, salt=reqID, info="iv") 的前 12 字节,显式派生而不是全零。虽然每个请求
都用一次性密钥、全零 IV 在实践中不会重用,但一旦有人日后把 S_req 改成按会话复用(很常见的
"优化"),全零 IV 立刻变成 nonce-reuse 灾难;显式派生把这条退路堵死。
重放防护:agent 维护 reqID 的 LRU(建议 4096 条)拒绝重复;拒绝 |now - ts| > 120s 的请求;
不执行过期请求。不需要额外的 nonce 字段——req 是 uuid4 且被签名覆盖,中继无法把一个有效请求
的 req 换掉再投,因此它本身就是唯一性来源。
时钟偏移是本方案最容易被忽略的故障源:节点设备如果长期离线且时钟漂移,表现为"所有指令都被拒", 所以 agent 日志必须把拒绝原因写成可区分的三类(签名无效 / 重放 / 超时),手机侧错误文案也要能 区分,否则排查会非常痛苦。
4. agent 侧的授权语义
中继不得授权任何事。 危险写的确认必须由手机产生、由 agent 校验,因为中继在本方案里是
不可信方。这与现有控制台的做法不同:server/proxy/confirmations.js 的一次性令牌是服务端签发
的,在"服务端可信"的前提下成立;搬到中继模式后签发方变成不可信方,语义就空了。
替代做法:危险写要求内层带 confirm.intent,其值为用户明确点击确认后计算的
sha256(method|path|规范化 body)。agent 重算并比对,因此 intent 与实际操作强绑定——中继无法
拿一个"确认过发短信"的 intent 去执行"改 APN"。同时 agent 本地限流(默认危险写 6 次/分钟)并
落审计日志。
诚实说明弱化点:这防的是中继和外部攻击者,防不了手机本身被控。手机丢了要靠中继侧吊销设备 (第 5 节),而吊销是"拒绝投递",agent 侧仍会接受该密钥的指令,直到你重新配对。要做到 agent 侧即时吊销,必须让 agent 可编辑受信设备密钥集,并在每次连上中继时拉一次密钥集快照——建议纳入 第一期,因为它决定了"丢手机"这件事的真实后果。
5. 中继
职责只有四件:鉴权、投递、关联、超时。它没有解密能力,也不需要节点凭据。
- 设备鉴权:配对时下发的长随机 bearer token(仅用于访问控制与滥用限制,不构成信任)。
- agent 鉴权:bearer token + 用
K_agent_sign签一个服务端随机数。仅偷到 token 无法冒充 agent。 - 注册表:
nodeID -> 唯一活跃会话(长轮询挂起槽,后到的 agent 握手顶掉前一个);agent 断线即标记不可达,不做请求排队等待重连。 - 邮箱:每节点一条有界 FIFO(深度建议 8),
reqID关联请求与响应,30s 超时向上返回 - 错误一律用仓库既有的 RFC 9457 信封
ProblemDetails(packages/contracts/src/errors.ts), 不另造形状;其中requestId直接取reqID,这样中继日志、控制台 request coordinator 与手机 三方可用同一个 id 串起来查。超时返回504+code: "relay_agent_timeout",不重试。有界队列 是刻意的:宁可明确失败,也不要让手机以为"发出去了"。 - 日志:只记元数据(nodeID、reqID、方法类别、字节数、时延、结果码)。这是本方案无法消除的泄露面。
6. iOS 侧改造
6.1 端点模型
现在 Instance.origin: URL 是非可选的,并且同时承担三件事:请求目标、去重键
(FileInstanceRepository 用 $0.origin == origin 抛 duplicateOrigin)、会话 Cookie 的绑定域
(InstanceSessionStore 按 (UUID, origin) 存,origin 变了即失效)。中继模式下这三个职责都要
重新安置,所以必须引入端点抽象而不是给 Instance 加一个可选的 relayNodeID 字段:
public enum InstanceEndpoint: Codable, Equatable, Sendable {
case direct(URL)
case relay(RelayEndpoint)
}
public struct RelayEndpoint: Codable, Equatable, Sendable {
public var relayBaseURL: URL
public var nodeID: String
public var agentSigningKey: Data // Ed25519 pub, 32B
public var agentKxKey: Data // X25519 pub, 32B
public var deviceToken: Data // 中继 bearer
}
迁移:Instance 的 Codable 改为容忍旧数据——init(from:) 先试 endpoint,缺失时把
origin 读成 .direct(origin)。去重键换成 endpoint.dedupKey(直通为 origin 绝对串,中继为
relay:<nodeID>)。写盘时统一输出 endpoint。旧包升级后行为不变,且不会把已有节点判成重复。
credentialConfigured 的语义要按模式分叉:直通表示"本机存了该节点密码",中继表示"agent 侧已
配置凭据"。两者不能混用同一个文案,否则用户会以为中继模式下手机里还有密码。
6.2 传输层
public protocol NodeTransport: Sendable {
func send(_ route: UpstreamRoute, endpoint: InstanceEndpoint) async throws -> UpstreamResponse
}
DirectTransport就是现有SimAdminClient的行为,含逐节点登录与 Cookie 管理,代码路径 与今天完全一致,不引入任何行为差异。RelayTransport只做信封构造、签名、加密、HTTPS 投递、验签、解密;不碰 Cookie,节点密码 从不出手机(agent 自己登录127.0.0.1)。
UpstreamRoute 无需改动:它已经是 method + path + body 的纯数据描述,天然可序列化进信封。
这是现有设计里少数直接能复用的部分。
6.3 模式选择
建议显式优先:每个实例一个明确模式,"自动"后置。理由是自动回退(直通短超时 1.5s 后转中继) 在非内网时会把每个读的延迟翻倍,且失败原因变得难以归因——用户看到的是"时快时慢",而不是 "不在内网"。自动模式应当等到中继跑稳、且能拿到网络可达性信号之后再上。
界面必须做两件事,否则运维上不可接受:
-
节点卡片显示通道徽标(直通 / 中继)。没有它,"这台为什么慢"无法回答。
-
错误分层。至少区分「中继不可达」「agent 离线」「信封被拒(验签或重放)」「节点返回错误」 四类。现有
UpstreamError有 7 个分支,但传输层失败全部塌进transportFailed与invalidResponse,无法表达上面这四类的区别;节点返回错误还能靠状态码分支,中继自身的失败 则完全没有对应分支。中继模式需要新增独立分支,不要复用transportFailed兜底,否则用户 看到的永远是同一句"请求失败"。错误码 vocabulary 建议落在
packages/contracts里以 const 数组枚举(照CAPABILITY_STATUSES的现有写法),由中继、agent、iOS 三方共享。现状是ProblemDetails.code在 OpenAPI 里只是 无约束string,全仓没有枚举,所以这是新增而非复用——但必须在第一期就枚举出来,否则三方 各写各的字符串,跨端排查立刻失效。
6.4 密钥存放
按既有约定,设备私钥与节点凭据同放应用沙盒文件,不进钥匙串。需要明确记录的代价:沙盒文件在
越狱设备上可被读取,Secure Enclave(SecureEnclaveKey)能消除这一点,但与"不用钥匙串"的
既定决策以及 TrollStore 签名流程冲突。这是本方案唯一悬而未决的安全取舍,需要你定。
好消息:Ed25519 / X25519 / AES-GCM / HKDF 全部在 CryptoKit 内,iOS 16 起可用,零新增依赖。
7. 配对流程
利用"配对时手机一定在内网"这个事实,不需要扫码:
- 节点上启动 agent,它临时在内网接口监听
:3001的管理端口,仅开放配对与自毁两个端点。 - 手机在直通模式下已经能访问该节点,于是把
{appSignPub, appKxPub, deviceName}POST 到http://<节点内网IP>:3001/provision,以节点密码作为共享秘密完成鉴权。 - agent 返回
{nodeID, agentSignPub, agentKxPub, relayURL, deviceToken},手机存为该实例的中继端点。 - agent 关闭管理监听,此后只出站连中继。
第 4 步是必须的:管理端口长期开着就等于在本方案之外又开了一个能注入受信设备的洞。
8. 策略文件必须共享,不能各自实现
server/proxy/policy.js 的 DEFAULT_READ_PATHS / DEFAULT_WRITE_PATHS 是本项目里最有价值的
现成资产,但它是 JS。如果 agent 用 Go/Rust 写,就会变成"两份手抄的白名单",而两份策略必然
漂移——漂移的后果是同一操作在网页控制台被拒、在手机中继下被放行,这类 bug 极难发现。
因此第一步应当是把策略抽成数据文件(packages/contracts/src/proxy-policy.json),控制台与
agent 都从它加载,并加一个测试断言两侧解析结果一致。这一步很小,但它决定后面所有安全性质
是否真的成立。
9. 实施阶段
| 阶段 | 内容 | 依赖 |
|---|---|---|
| P1 | 契约先行:策略抽为共享 JSON、枚举中继错误码、把 FRESHNESS_STATUSES 引到 iOS;加双端一致性测试 |
无 |
| P2 | agent:出站长轮询、信封验签解密、策略执行、回环硬校验、审计日志、本机登录、配对端点 | P1 |
| P3 | 中继:设备/agent 鉴权、注册表、有界邮箱、超时、元数据日志 | P1 |
| P4 | iOS:InstanceEndpoint + 迁移 + NodeTransport 抽象 + 密钥与配对 |
P2,P3 |
| P5 | iOS:通道徽标、分层错误、危险写确认、吊销与轮换入口 | P4 |
| P6 | 文档、部署脚本、渗透自查(重点:回环硬校验、重放、队列堆积) | P5 |
P2/P3 可并行。P4 是唯一动到现有 iOS 数据模型的部分,风险集中在迁移,建议单独一个分支并配 旧数据升级测试。
10. 已知代价
- 中继被攻破 = 服务不可用(拒绝投递),不等于数据泄露。这是本方案换来的东西。
- 元数据仍然泄露给 VPS:谁在何时对哪个节点做了哪类操作、响应多大。要连这个都不要,只能回到 VPN。
- 读操作两跳延迟,实时性弱于直通,节点状态必须能表达"这是多久以前的"。仓库里已有
FRESHNESS_STATUSES(fresh / stale / expired / unknown,packages/contracts/src/instances.ts), 但 iOS 侧目前完全没有这套 vocabulary,只有概览副标题一句"刚刚更新"。中继模式必须把 freshness 落到 iOS 数据模型与卡片上(沿用同一组四个状态值,不要另起名),否则用户无法判断 一个"在线"是当下的还是 40 秒前的——这在中继模式下是实质性误导。 - 每台节点多一个常驻进程,Android 上会被 Doze 杀,需要前台服务与电池优化豁免;这是运维负担, 不是理论问题。
- 内网仍无任何公网入口,但出站被完全掐断的环境(仅白名单出网的内网)会让 agent 无法工作。
11. 已确认项与仍待确认项
已确认(据此实现):
- 节点设备为 ARM 架构 Debian,且 SimAdmin 与 agent 同机运行。因此 agent 是 Go 静态二进制,
交叉编译
linux/arm64与linux/arm,回环硬校验(第 2 节)对所有节点成立,无需 CIDR 白名单。 - 设备私钥放在应用沙盒文件,不进钥匙串(6.4)。代价已记录,此处不再悬置。
- 8788 控制台与中继不同机,理由见第 2 项旧表:同机会把控制台推到公网口。
仍待确认:
- 第 10 节的元数据泄露(谁在何时对哪个节点做了哪类操作)是否接受。这是本方案不可消除的泄露面。
双临时缓存方案已定:响应方向实现为对手机请求临时ek加密(双向临时),无需缓存; 请求方向维持临时-静态,即明确接受"K_agent_kx.priv泄露可解密历史请求"。若要连请求方向也 补齐,再评估缓存方案(代价见第 3 节修订注)。- agent 侧即时吊销(每次连上中继拉一次受信设备密钥集快照)是否纳入第一期。不纳入则"丢手机" 的真实后果是:中继能拒绝投递,但 agent 仍接受该密钥的指令直到重新配对。
12. 本方案明确不做的事
- 不做端口映射、DDNS、frp/ngrok/Cloudflare Tunnel 公网 hostname。
- 不让中继持有或代管节点密码。
- 不在 iOS app 内嵌 VPN 网络扩展(需要 Apple 签发的 entitlement 与带 app-extension 的 bundle,当前签名流程不适合)。
- 不引入"自动选路"作为默认行为。