Files
multi-simadmin/docs/ios-relay-architecture.md
T
chick 95c653b3d3 docs(ios): 双通道(内网直通 + 公网中继)设计稿
- 拓扑:每节点一个 agent,只出站连中继,出口硬编码回环,把 SSRF 爆炸半径从整个内网收缩到单节点
- 信封:Ed25519 签名覆盖 nodeID/reqID/ek 防改投与错配,临时-静态 DH 加密,并写准前向安全的实际边界
- 授权:中继不得授权任何事,危险写确认改由手机产生、agent 校验,说明服务端签发令牌为何不可搬用
- iOS:引入 InstanceEndpoint 与 NodeTransport,origin 同时承担目标/去重键/Cookie 绑定三职的迁移路径
- 复用既有 ProblemDetails、FRESHNESS_STATUSES 与 server/proxy 策略,并要求策略抽为共享 JSON 防漂移
2026-09-15 14:55:45 +08:00

18 KiB
Raw Blame History

iOS 双通道方案:内网直通 + 中继到节点 agent

状态:设计稿(未实施)。目标是让 iPhone 在不出 VPN、不做内网穿透映射的前提下管理内网节点, 同时保留现有局域网直通能力。

1. 约束与结论

三条约束里只能同时满足两条,这是本方案的全部出发点:

  1. 手机不依赖 VPN 客户端。
  2. 内网不存在任何公网可解析的入口(端口映射、DDNS、frp/ngrok/Cloudflare Tunnel 的公网 hostname 都算,判定标准是"打到这个名字能不能进内网",与连接建立方向无关)。
  3. 中继服务器不参与信任。

因此拓扑必须是:手机 → 公网中继 → 节点 agent,其中 agent 只发起出站连接,且中继看不懂 也改不动流经它的指令。第 3 条不是靠"尽量安全"实现的,而是靠逐条签名 + 逐条加密实现的; 少了密码学,这套东西就只是一个更难用的 VPN。

iPhone ──HTTPS(签名信封)──> 中继(VPS, 哑投递) ──<──出站 WSS── 节点A agent ──HTTP──> 127.0.0.1:3000
   每节点一条 agent 连接,agent 只被允许访问本机回环

<── 表示连接由 agent 侧发起,内网无任何监听端口。

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,
  "nodeID": "nd_7f3a...",
  "reqID": "<uuid4>",
  "ts": 1727289600,
  "ek": "<base64 32B>",      // 本次请求的临时 X25519 公钥
  "payload": "<base64 AES-256-GCM>",
  "sig": "<base64 Ed25519>"
}

sig 覆盖 {v,nodeID,reqID,ts,ek,payload} 的规范化字节串(固定字段序 + 长度前缀,不做 JSON 字符串拼接签名)。这一点是实质性的:签名包含 nodeID,中继就无法把一个对节点 A 有效的请求 改投给节点 B;签名包含 reqID 与 ek,就无法做响应错配或临时公钥替换。

payload 解密后的内层:

{ "method": "POST", "path": "/api/sms/send", "query": {}, "body": {...},
  "confirm": {"intent": "<sha256(method|path|canonical body)>"},
  "deadline": 1727289630 }

密钥派生:请求方向 HKDF(X25519(appEphPriv, K_agent_kx.pub), salt=nodeID, info="msa-relay-v1-req"), agent 侧用 X25519(K_agent_kx.priv, ek) 得到同一密钥;响应方向由 agent 每次新生成临时密钥对, 把公钥放在响应信封的 ek 里(同样被签名覆盖),手机用 X25519(K_app_kx.priv, ek) 解密。

这里的前向安全性质要说准,不能含糊。 两个方向都是"临时-静态"单消息 DH(HPKE base 模式的 同一形状),因此每个方向只对发送方的长期密钥泄露是前向安全的,对接收方的不是:

  • 请求方向的 ek 以明文出现在被签名的信封里。日后 K_agent_kx.priv 泄露的人,可以用它和录到的 ek 直接算出历史请求的密钥,从而读出历史请求内容。
  • 响应方向同理:K_app_kx.priv 泄露即可解密录到的历史响应。
  • 反过来,appEphPriv 与 agentEphPriv 用后即弃且从不上传线,所以只泄露"发送方"的长期密钥 不足以解密历史。

如果要求双向都对长期密钥泄露前向安全,必须让两个方向都变成"临时-临时",即双方各贡献一个临时 密钥。可行的低成本做法:agent 在每个响应信封里带上自己的当前临时公钥(被 K_agent_sign 签名覆盖),手机缓存它,下一次请求用这个缓存公钥而非 K_agent_kx.pub。代价是配对后的第一条 请求没有缓存、只能用静态公钥引导(bootstrap 那一条不具前向安全),以及 agent 重启后缓存需要 失效重取。建议直接按缓存方案实现,因为节点凭据与短信内容都在 payload 里,"中继被长期记录 + 日后某台 agent 被拔盘"并不是遥远的组合。是否接受 bootstrap 那一条的弱化需要确认。

GCM IV 取 HKDF(S_req, salt=reqID, info="iv") 的前 12 字节,显式派生而不是全零。虽然每个请求 都用一次性密钥、全零 IV 在实践中不会重用,但一旦有人日后把 S_req 改成按会话复用(很常见的 "优化"),全零 IV 立刻变成 nonce-reuse 灾难;显式派生把这条退路堵死。

重放防护:agent 维护 reqID 的 LRU(建议 4096 条)拒绝重复;拒绝 |now - ts| > 120s 的请求; deadline 到期即丢弃,不执行。不需要额外的 nonce 字段——reqID 是 uuid4 且被签名覆盖,中继 无法把一个有效请求的 reqID 换掉再投,因此它本身就是唯一性来源。

时钟偏移是本方案最容易被忽略的故障源:节点设备如果长期离线且时钟漂移,表现为"所有指令都被拒", 所以 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 -> 唯一活跃 WSS 会话;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 后转中继) 在非内网时会把每个读的延迟翻倍,且失败原因变得难以归因——用户看到的是"时快时慢",而不是 "不在内网"。自动模式应当等到中继跑稳、且能拿到网络可达性信号之后再上。

界面必须做两件事,否则运维上不可接受:

  1. 节点卡片显示通道徽标(直通 / 中继)。没有它,"这台为什么慢"无法回答。

  2. 错误分层。至少区分「中继不可达」「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. 配对流程

利用"配对时手机一定在内网"这个事实,不需要扫码:

  1. 节点上启动 agent,它临时在内网接口监听 :3001 的管理端口,仅开放配对与自毁两个端点。
  2. 手机在直通模式下已经能访问该节点,于是把 {appSignPub, appKxPub, deviceName} POST 到 http://<节点内网IP>:3001/provision,以节点密码作为共享秘密完成鉴权。
  3. agent 返回 {nodeID, agentSignPub, agentKxPub, relayURL, deviceToken},手机存为该实例的中继端点。
  4. 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:出站 WSS、信封验签解密、策略执行、回环硬校验、审计日志、配对端点 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. 待你确认

  1. 节点设备的操作系统与架构。决定 agent 用静态 Go 二进制、Rust,还是 Android 前台服务, 也决定 P2 的工作量。上游是 Rust 后端 + 电话类 API(SIM/短信/频段锁/eUICC LPAC),但我在 仓库里没找到节点运行环境的记载,这个信息只能由你提供。
  2. 8788 控制台与中继是否同机部署。同机能少一套 TLS 与进程管理,但控制台目前绑 127.0.0.1, 同机意味着它也要开始监听公网口,等于扩大控制台暴露面,我倾向不同机。
  3. 设备私钥放沙盒文件还是必须进 Secure Enclave(6.4)。
  4. 所有节点是否都在同一内网且 SimAdmin 就在本机回环。跨网段或异机的节点不适用第 2 节的 回环硬校验,需要显式 CIDR 白名单,安全性质会下降一档。
  5. 第 10 节的元数据泄露是否接受。
  6. 是否采用第 3 节的双临时缓存方案(代价:配对后第一条请求不具前向安全 + agent 重启需失效重取)。 不采用则明确接受"任一长期密钥泄露可解密历史流量"。

12. 本方案明确不做的事

  • 不做端口映射、DDNS、frp/ngrok/Cloudflare Tunnel 公网 hostname。
  • 不让中继持有或代管节点密码。
  • 不在 iOS app 内嵌 VPN 网络扩展(需要 Apple 签发的 entitlement 与带 app-extension 的 bundle,当前签名流程不适合)。
  • 不引入"自动选路"作为默认行为。