Apple 端(macOS / iOS)加入网络与登录流程改版设计
- 状态:提议中
- 日期:2026-09-19
- 范围:
apple/下的 macOS App(LatticeMac)与 iOS App(Lattice),以及它们共用的Shared/ - 关联:
docs/superpowers/specs/2026-05-13-token-system-enhancement-design.md(lattice://join与登录同时换取入网令牌)、docs/adr/0003-peer-enrollment-approval-and-client-side-keygen.md(入网审批)
1. 背景与问题
现在要让一台 Mac 或 iPhone 真正用起来,需要走两套互不相干的流程、填两套凭据:
| 流程 | 入口 | 要填什么 | 做什么用 |
|---|---|---|---|
| 加入网络 | mac:ContentView.swift 的 JoinView;iOS:Lattice/JoinView.swift | 服务器地址、入网令牌、设备名 | 让这台设备成为网络里的一个节点(隧道) |
| 管理登录 | mac:ContentView.swift 的 SettingsView;iOS:Lattice/LoginView.swift | 服务器地址、用户名、密码 | 查看和管理节点(列表、改名、下线、删除、ACL、出口节点、意图) |
实际使用中的问题:
- 要做两次,服务器地址要填两遍。两个表单读写同一个
lattice.serverURL,但用户仍要在各自的界面里确认一次。 - 看节点列表也要先登录。
LatticeAPI.listPeers()走管理接口/api/v1/peers/list,没登录就拿不到列表;而隧道进程通过 provider message 回给界面的只有peerStates(名称到状态的字典)、lastError、publicKey、overlayIP,不含地址等信息。 - 状态和原因不清楚。2026-09-19 联调时,Mac App 因设备名带空格失败,用户看到的一直是"连接中",真正的原因(
record not found、nats connect ... i/o timeout)是靠读隧道日志才定位的。TunnelManager会轮询隧道回传的lastError,但这次没有形成用户能看懂的提示。Apple 端代码里也没有"等待管理员批准"这个状态:ADR-0003 开启RequirePeerApproval后,新设备会处于pending并拿到空网络图,界面上会表现成连不上。 - 管理令牌存放不当。
LatticeAPI把管理令牌lattice.authToken放在UserDefaults(明文),只有密码放在钥匙串。 - 邀请链接只被部分利用。控制台(
frontend/src/pages/manage/tokens/index.vue)已经能生成lattice://join?server=…&token=…的二维码,JoinPayload能解析server与token;但 2026-05-13 的设计里约定的name参数没有被解析,Mac 端也没有"粘贴链接即加入"的入口,lattice://也没有注册为 URL scheme,点链接不能直接打开 App。
2. 目标与非目标
目标:
- 入网是唯一必须的一步,新设备从打开 App 到连上网络,只需要一次输入(粘贴链接、扫码,或在有账号时登录)。
- 主界面不需要登录就能看到节点、IP、连接方式(直连或中继)。
- 只有真正的管理操作才要求登录,登录一次后不再反复询问。
- 任何失败都给出原因和下一步建议,而不是一直"连接中"。
- macOS 与 iOS 的流程和文案一致,只有输入方式不同(iOS 扫码,Mac 粘贴或拖入)。
非目标:SSO(按既有决定暂缓,入口保持现状)、改动服务器 API 语义(除第 6 节提到需要确认的两点)、UI 视觉风格重做。
3. 设计原则:两类凭据,各管各的
- 设备凭据:入网令牌。作用是让设备注册并建立隧道。放在 VPN 配置的
providerConfiguration里(TunnelManager.createProfile),由隧道进程使用。用户只在入网时接触它。 - 管理凭据:账号密码,换来的管理令牌用于调用管理 API。只在用户要做管理操作时才需要。
这两类凭据不应该再在界面上并列出现。入网流程只碰前者;后者只在被需要的那一刻才出现。
4. 新流程
4.1 状态机
未加入 ──输入链接/令牌──► 入网中 ──注册成功──► 已获批? ──否──► 等待批准
│ │是 │批准后
▼ ▼ ▼
失败(带原因) 连接中(协商) ──► 已连接
│
▼
失败(带原因)各状态的界面文案:
| 状态 | 主文案 | 说明与操作 |
|---|---|---|
| 未加入 | 加入网络 | 见 4.2 |
| 入网中 | 正在向服务器注册… | 显示服务器地址;可取消 |
| 等待批准 | 等待管理员批准 | 说明"管理员批准后会自动连接",显示设备名和公钥指纹,方便管理员核对;可退出网络 |
| 连接中 | 正在建立连接… | 显示已连上的节点数;超过 30 s 仍未连上时给出提示和"查看诊断" |
| 已连接 | 已连接 · 10.96.0.x | 节点列表见 4.4 |
| 失败 | 显示原因(见下表)与"重试" | "复制诊断信息"导出隧道日志末尾若干行 |
失败原因的归类(根据引擎报出的错误映射,文案是建议):
| 引擎错误特征 | 用户看到的原因 | 建议 |
|---|---|---|
| 服务发现请求失败、超时 | 连不上服务器 | 检查服务器地址与网络;如在使用代理或 VPN,给服务器地址加直连规则 |
nats connect … i/o timeout | 连不上信令端口(4222) | 同上;确认云安全组放行 4222/tcp |
| 注册被拒:令牌无效、已过期、次数用完 | 入网令牌不可用 | 向管理员重新获取邀请链接 |
| 提示需要重新入网(公钥不匹配) | 这台设备的身份与服务器记录不一致 | 选择"重新入网" |
注册成功但 pending | 见"等待批准"状态 | 无 |
| 其他 | 原始错误信息 | 复制诊断信息 |
4.2 入网入口:一个输入框
首屏只有一个入口:
- 粘贴邀请链接或令牌:输入框接受完整的
lattice://join?server=…&token=…&name=…,也接受只有令牌的字符串。打开界面时如果剪贴板内容能被JoinPayload解析,直接预填并提示"检测到邀请链接"。 - 扫码:iOS 现在首页有"扫描二维码"和"手动输入"两个入口(
OverviewView里用sheet(item:)区分模式),合并成上面这个输入框,并在输入框旁保留扫码按钮;Mac 保留现有扫码窗口,并增加拖入二维码图片。 - 只有令牌、没有服务器地址时,才展开"服务器地址"一栏(上次成功的地址作为默认值)。
- 设备名默认取主机名,折叠在"高级"里;名称包含空格时,界面提示"将保存为
MacBook-Pro",与服务器的规范化结果一致(见infra.NormalizeAppID)。
JoinPayload 增加可选的 name 参数,其余不变,旧链接继续可用。
点击"加入"之后,首次会弹出系统的 VPN 配置授权,界面在此之前先给一句说明,避免用户以为是异常。
4.3 有账号时:登录即入网(可选路径)
对有账号的管理员,提供第二个入口"用账号登录并加入":输入服务器地址、用户名、密码后:
- 登录,取得管理令牌与工作区;
- 以
X-Workspace-Id调用POST /api/v1/token/generate,为这台设备生成一个入网令牌; - 用该令牌走与 4.2 相同的
saveJoin。
结果是一次输入同时得到设备身份和管理登录状态,之后管理操作不必再登录。这与 2026-05-13 设计里"lattice login 一次认证,同时返回管理令牌与入网令牌"是同一思路。被邀请的普通设备不需要这条路径,仍用 4.2。
4.4 节点列表不依赖登录
主界面的节点列表、状态、IP 直接取自隧道自己的网络图:
- 隧道进程(
apple/engine)在现有 provider message 之外新增peers消息,返回每个节点的名称、地址、传输方式(直连、中继、协商中、离线)、最近握手时间。数据来源直接复用Node.StatusSnapshot(lattice status用的同一份,见internal/agent/status_peers.go)。 - 已登录时,再用管理 API 的数据补充显示名、标签、下线状态、通告路由、最近在线时间等增强字段,按节点名称合并。
- 未登录时,这些增强字段不显示,也不再弹出"请先登录"的整页错误。
4.5 管理操作按需登录
改名、下线、删除、ACL、出口节点、意图这类操作,点击时如果没有有效的管理令牌:
- 弹出"登录以管理"的面板,服务器地址已预填,用户名带出上次使用的;
- 登录成功后自动继续刚才的操作;
- 凭据存钥匙串,管理令牌过期(7 天)时沿用现在的静默重新登录(
LatticeAPI.relogin)。
管理令牌从 UserDefaults 迁移到钥匙串,读到旧值时迁移一次并删除旧键。
5. 界面草图
首屏(未加入):
┌────────────────────────────────────────┐
│ 加入网络 │
│ │
│ ┌────────────────────────────────────┐ │
│ │ 粘贴邀请链接或令牌 │ │
│ └────────────────────────────────────┘ │
│ 检测到剪贴板中的邀请链接 [使用] │
│ │
│ [扫码加入] [用账号登录并加入] │
│ │
│ ▸ 高级(服务器地址、设备名) │
│ [ 加入 ] │
└────────────────────────────────────────┘失败:
┌────────────────────────────────────────┐
│ ⚠ 连不上信令端口(4222) │
│ 检查网络或代理设置;如果使用代理,请给 │
│ 服务器地址添加直连规则。 │
│ [重试] [复制诊断信息] [退出网络] │
└────────────────────────────────────────┘6. 需要服务器确认的两点
POST /api/v1/token/generate目前挂在WorkspaceAuthMiddleware(RoleViewer)之下,也就是工作区里最低的 Viewer 角色就能生成令牌。4.3 依赖这个接口,是否允许普通成员自助入网,需要产品上确认;如需收紧,4.3 只对管理员开放。- 入网令牌的语义(单次使用、有效期、使用次数上限)以服务器实际实现为准,App 需要把"令牌已用完"和"已过期"分开提示,前提是服务器返回可区分的错误信息。
7. 兼容与迁移
- 已入网的设备不受影响,
lattice.joined等既有状态保留。 - 旧邀请链接、旧二维码继续可用(
name可选)。 lattice.authToken迁移到钥匙串(4.5)。- 隧道进程新增的
peers消息是追加,旧版界面不发这条消息就不受影响。
8. 实施拆分
| 阶段 | 内容 | 主要文件 |
|---|---|---|
| P1 | 入网单输入框;JoinPayload 增加 name;状态与失败原因文案;服务器地址只填一次 | Shared/JoinPayload.swift、mac JoinView、iOS Lattice/JoinView.swift、Shared/TunnelManager.swift |
| P2 | 隧道新增 peers 消息;节点列表脱离登录 | apple/engine/engine.go、LatticeTunnel*/PacketTunnelProvider.swift、Shared/TunnelManager.swift |
| P3 | 管理操作按需登录;管理令牌迁移到钥匙串 | Shared/LatticeAPI.swift、mac SettingsView、iOS LoginView |
| P4 | 用账号登录并加入 | Shared/LatticeAPI.swift(新增生成令牌)、两端入网界面 |
| P5 | "等待批准"状态 | 需要引擎把 pending 作为独立事件抛出,见第 9 节第 1 点 |
8.1 P1 实施记录(2026-09-19)
已实现(macOS 与 iOS):
- 入网只有一个输入框,接受
lattice://join?server=…&token=…&name=…链接或裸令牌;服务器地址和设备名折叠在"高级"里,输入里没有服务器地址时自动提示并展开。JoinPayload增加可选的name,旧链接不受影响。 - 设备名带空格等字符时,"高级"里显示"将保存为 …"(
DeviceName.normalized,与 Go 的infra.NormalizeAppID规则一致)。App 里记录"本机名称"改为规范化后的名字,这样节点列表能认出"这台设备"(原来带空格的名字会对不上)。 - 失败原因归类(
JoinFailure.classify):把引擎和服务器的原始报错映射成"原因 + 建议",在 macOS 主界面和 iOS 概览页显示;无法识别的错误仍显示原文。 - 与设计的差别:打开入网界面时只会自动读取剪贴板里完整的邀请链接;裸令牌只能点"粘贴"按钮读取,避免把用户复制过的任意单词(比如密码)显示到输入框里。
验证方式:apple/Scripts/test_apple_logic.sh 编译并运行链接解析、名称规范化、失败归类、节点列表合并的检查(这些代码只依赖 Foundation,不需要 Xcode 测试 target);两端 App 均已构建通过。界面本身还没有做人工走查。
8.2 P2 实施记录(2026-09-19)
已实现(macOS 与 iOS):
- 引擎新增
Engine.Peers(),返回隧道自己的节点列表 JSON(名称、地址、平台、连接状态、是否在线),数据来自网络图和探测状态,排除本机和还没有地址的节点;构造逻辑是不依赖 gomobile 的纯函数(apple/engine/peers.go,有单测)。 - 两个隧道扩展在
peerStates消息的回复里增加peers字段,TunnelManager解码为tunnelPeers,只在内容变化时更新,隧道断开时清空。 - 节点列表 = 管理 API 数据与隧道数据的合并(
PeerListMerge.merged):两边都有的节点以 API 为准(显示名、标签、路由、停用状态),只有隧道知道的节点追加在后面,没登录时列表完全来自隧道。PeerNode.id改为稳定的(AppID,缺失时用名称),原来每次重建都会生成新的 UUID,列表每 2 秒被当成全新数据。 - 未登录时不再请求管理 API,也不再自动弹出登录页;列表上方是一条"登录后可管理设备"的提示。登录已过期但隧道有数据时同样只提示。
已知不足:未登录时点节点行上的改名、下线、删除仍会调用管理 API 并报错,这属于 P3(按需登录)。列表里还没有最近握手时间。
8.3 P3 实施记录(2026-09-19)
已实现(macOS 与 iOS):
- 管理令牌迁移到钥匙串:
AuthTokenStore优先读钥匙串,读到UserDefaults里的旧明文令牌时迁移一次,确认钥匙串里确实写进去了才删除旧值(写入失败则保留旧值,令牌仍可用)。写入永远不再留明文副本。存取抽成可注入的接口,用内存假实现做了测试。 - 按需登录:
LatticeAPI里凡是写操作(改名、下线、删除、设置路由、意图、AI 对话等,即非 GET 请求)在没有登录时,会通过LoginCoordinator暂停,界面弹出"登录以管理设备"面板,登录成功后自动继续刚才的操作,取消则返回"已取消登录"。同时有多个操作在等,会一起继续。只读请求不弹窗。保存的凭据失效(401 且静默重新登录失败)时,用户发起的操作同样会重新询问。 - 面板:macOS 用紧凑的
ManageLoginView(服务器地址只读显示、用户名带出上次使用的),只在主窗口弹出,菜单栏面板不弹;iOS 复用LoginView,挂在RootView上。设备列表上方的"登录后可管理设备"提示直接打开这个面板。 - 登录状态可观察:新增
AuthSession,替代 iOS 里对@AppStorage("lattice.authToken")的依赖(令牌进了钥匙串,SwiftUI 观察不到);登录、退出后列表自动刷新。新增LatticeAPI.logout(),退出登录、退出网络、重新生成密钥都走它,统一清理令牌、保存的密码和工作区缓存。
已知不足:节点详情里的只读内容(例如 ACL)未登录时仍会失败并显示错误,没有单独的"登录后查看"提示;设置页里原有的登录表单保留未动。
8.4 P4 实施记录(2026-09-19)
已实现(macOS 与 iOS):
- 入网界面顶部有"邀请链接 / 令牌"和"账号登录"两种方式。账号登录只需要服务器地址、用户名、密码,设备名折叠在"高级"里;按钮是"登录并加入"。
- 流程:登录(
lattice.serverURL先写成填的地址)→ 为这台设备生成一个入网令牌(POST /api/v1/token/generate,工作区取登录时解析出的那个)→ 用这个令牌走与邀请链接相同的入网流程。登录状态保留在钥匙串里,之后的管理操作不再需要登录。 - 失败会说明是哪一步:登录失败,或登录成功但没能签发令牌;后者对权限不足(服务器返回
Insufficient permissions)给出专门的说明和建议。 - 令牌参数:不指定名字(由服务器生成随机令牌;指定名字时令牌值就是这个名字,容易重复也容易猜),使用次数 1,有效期 8760 小时。
必须记住的一个服务器行为:注册时服务器先检查令牌是否过期,然后才判断"已有设备重新注册,不受使用次数限制"。而客户端每次启动引擎都会用保存的入网令牌重新注册,所以令牌一旦过期,已经入网的设备下次重新连接就会被拒绝(token is expired)。服务器默认有效期只有 168 小时,这也是 P4 选了一年的原因。测试环境里现有的入网令牌都是默认的 7 天,最早的一批在 2026-09-25 到期,到期后使用它们的设备(云主机上的容器、Mac、手机)重新注册会失败。
已在服务器上修复(2026-09-20,提交 a9129475,已部署到测试云主机):过期检查只对"不是用这个令牌入网的设备"生效。已入网的设备用自己入网时的令牌重新注册,令牌过期后照样能恢复,因为 GetNetmap 本来就无条件接受这个令牌,拒绝注册只会把已入网的设备锁在外面,并不能保护什么;过期的令牌仍然不能让新设备入网,也不能接管别的令牌入网的设备(三种情况都有测试)。现有测试环境的令牌不需要重发。新生成的令牌默认有效期仍是 7 天,它只决定新设备的入网窗口。
8.5 P5 实施记录(2026-09-19)
第 9 节第 1 个问题的答案:原来没有。设备被工作区的入网审批(ADR-0003)挂起时,服务器返回的网络图里 Current 带着 approvalStatus: pending 和空地址,但客户端 fetchNetMap 只认"有地址",等满 60 秒就报"timed out waiting for VPN IP allocation",界面表现为连接失败。
已实现(引擎、macOS、iOS):
- 识别:
classifyNetmap把网络图分成等待分配、待批准、已撤销、就绪四种。待批准时只通知一次(onPending),之后一直等到批准,不再有 60 秒的分配超时(服务器一直有应答就一直等),轮询间隔从 0.5 秒逐步放慢到 15 秒;已撤销返回ErrDeviceRevoked;没有审批时行为不变,仍然 60 秒超时。取消(停止引擎)会立即结束等待。新增RegisterSandboxViaNATSNotify,原函数保持不变。 - 引擎事件:待批准时引擎发出
awaiting-approval;批准后继续正常入网,无需用户操作。 - 界面:两个隧道扩展在回复里增加
phase,TunnelManager.awaitingApproval为真时,lastFailure是"等待管理员批准 / 管理员批准后会自动连接",用橙色(提示)而不是红色(错误)显示,macOS 主界面和 iOS 概览页都是;已撤销显示"这台设备已被管理员停用"。 - 顺带修复的已有缺陷:
TunnelManager只在隧道已连接时才轮询隧道进程,代码注释写着"连接中也轮询",实际没有,所以连接过程中(包括启动失败)界面拿不到任何原因,只能一直显示"连接中"。现在连接中也轮询;同时"隧道进程无响应"的提示只在连接超过 15 秒仍无回应时才出现,避免扩展刚启动时误报。
验证方式:internal/agent/sandbox_register_test.go 用脚本化的假服务器测试待批准会一直等(超过分配超时)、只通知一次、撤销报错、无审批仍超时、取消能结束等待;Swift 侧有失败文案和提示样式的检查。
还没有验证的:真实的审批流程(需要在工作区开启入网审批,用一个新设备加入,看到等待状态,再批准)。一个具体的风险是隧道扩展在等待期间一直没有完成 startTunnel,系统是否会因超时而结束扩展,这一点只能在真机上确认。
未做:URL scheme 注册(第 9 节第 2 点)。
9. 待确认的问题
- 代理侧(
internal/agent)遇到pending注册响应时,引擎目前是否已经把它作为独立状态抛给上层?如果没有,P5 需要先在引擎里补这个事件。 lattice://这个 URL scheme 目前在两端的Info.plist和project.yml里都没有找到注册(没有CFBundleURLSchemes),所以从浏览器点邀请链接不能直接唤起 App。P1 是否顺带注册并处理onOpenURL?- 令牌 TTL 与次数上限(第 6 节第 2 点),以及"账号登录并加入"生成的令牌是否应该绑定这台设备、短有效期。
- SSO 恢复后,"用账号登录并加入"是否改为浏览器登录回调(2026-05-13 设计中的 SSO 扫码场景)。
10. 验证清单
- 全新安装:粘贴完整链接、只粘贴令牌、扫码,三种方式都能加入;系统 VPN 授权弹出前有说明。
- 设备名带空格(如
MacBook Pro):能加入,界面提示规范化后的名称,节点列表里名称与服务器一致。 - 服务器 4222 不通或超时:界面 30 s 内给出"连不上信令端口"和重试,而不是一直"连接中"。
- 令牌无效、过期:分别提示。
- 开启入网审批:显示"等待管理员批准",批准后自动连上。
- 未登录:能看到节点列表和连接方式;点击改名弹出登录面板,登录后自动完成改名。
- 管理令牌过期:静默重新登录,操作不中断。
- 旧版本升级后:已入网设备保持连接,管理令牌完成迁移。
- macOS 与 iOS 分别过一遍,文案一致。