feat(ios): 中继通道 P4/P5——端点抽象、信封、传输、配对与分层错误
- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import Foundation
|
||||
|
||||
/// How a node is reached, as a value the list can badge.
|
||||
public enum InstanceChannel: String, CaseIterable, Sendable {
|
||||
case direct
|
||||
case relay
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .direct: return "直通"
|
||||
case .relay: return "中继"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension InstanceEndpoint {
|
||||
var channel: InstanceChannel {
|
||||
switch self {
|
||||
case .direct: return .direct
|
||||
case .relay: return .relay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the app reaches a node.
|
||||
///
|
||||
/// This exists rather than an optional `relayNodeID` field on `Instance` because `origin` currently
|
||||
/// carries three separate responsibilities — the request target, the duplicate-detection key, and
|
||||
/// the cookie binding domain — and in relay mode all three have to be re-homed. An optional field
|
||||
/// would leave two of them quietly pointing at an address that does not exist on a relay node.
|
||||
public enum InstanceEndpoint: Equatable, Sendable {
|
||||
/// The node is reachable directly on the local network. This is the original behaviour and its
|
||||
/// code path is unchanged.
|
||||
case direct(URL)
|
||||
/// The node is reached through a relay, with the agent on the node holding the session against
|
||||
/// the local SimAdmin. No node password is stored on the phone in this mode.
|
||||
case relay(RelayEndpoint)
|
||||
}
|
||||
|
||||
public struct RelayEndpoint: Equatable, Sendable {
|
||||
/// The relay's base URL, https. The only outbound destination in relay mode.
|
||||
public var relayBaseURL: URL
|
||||
/// The node this endpoint reaches, as the relay and the agent both name it.
|
||||
public var nodeID: String
|
||||
/// `K_agent_sign` public half, Ed25519, 32 bytes. Every response is verified against it.
|
||||
public var agentSigningKey: Data
|
||||
/// `K_agent_kx` public half, X25519, 32 bytes. Requests are encrypted to it.
|
||||
public var agentKxKey: Data
|
||||
/// The bearer the relay expects from this phone. It authorises nothing on the node: the node's
|
||||
/// authorisation comes from the signature the phone produces with its own private key.
|
||||
///
|
||||
/// A `String` rather than `Data`, which the design sketch drew: the relay compares the token as
|
||||
/// an opaque bearer value, so what has to survive the round trip is the exact text the node
|
||||
/// minted. Holding it as bytes invites a hex-encode on the way out, which sends a different
|
||||
/// string than the one the agent registered and fails as "unknown device" with nothing to see.
|
||||
public var deviceToken: String
|
||||
/// The device id the node knows this phone by, and which the phone signs into every request.
|
||||
public var deviceID: String
|
||||
/// The node's display name at pairing time, used only when the user has not renamed it.
|
||||
public var nodeName: String?
|
||||
|
||||
public init(
|
||||
relayBaseURL: URL,
|
||||
nodeID: String,
|
||||
agentSigningKey: Data,
|
||||
agentKxKey: Data,
|
||||
deviceToken: String,
|
||||
deviceID: String,
|
||||
nodeName: String? = nil
|
||||
) {
|
||||
self.relayBaseURL = relayBaseURL
|
||||
self.nodeID = nodeID
|
||||
self.agentSigningKey = agentSigningKey
|
||||
self.agentKxKey = agentKxKey
|
||||
self.deviceToken = deviceToken
|
||||
self.deviceID = deviceID
|
||||
self.nodeName = nodeName
|
||||
}
|
||||
}
|
||||
|
||||
public extension InstanceEndpoint {
|
||||
/// The direct origin, or nil in relay mode.
|
||||
///
|
||||
/// Callers that need "the address to dial directly" get nil rather than a substituted value, so
|
||||
/// a relay instance cannot accidentally be handed to a code path that would dial a LAN address
|
||||
/// that is not there.
|
||||
var directOrigin: URL? {
|
||||
if case .direct(let url) = self { return url }
|
||||
return nil
|
||||
}
|
||||
|
||||
var relay: RelayEndpoint? {
|
||||
if case .relay(let endpoint) = self { return endpoint }
|
||||
return nil
|
||||
}
|
||||
|
||||
var isRelay: Bool { relay != nil }
|
||||
|
||||
/// The identity used for duplicate detection.
|
||||
///
|
||||
/// Direct endpoints keep using the normalised origin so an existing node list upgrades without
|
||||
/// suddenly reporting duplicates; relay endpoints use the node id, because two phones paired to
|
||||
/// the same node are two different instances and one phone paired to two nodes is not.
|
||||
var dedupKey: String {
|
||||
switch self {
|
||||
case .direct(let url):
|
||||
return url.absoluteString
|
||||
case .relay(let endpoint):
|
||||
return "relay:\(endpoint.nodeID)"
|
||||
}
|
||||
}
|
||||
|
||||
/// What the user sees as "where this node is".
|
||||
var displayAddress: String {
|
||||
switch self {
|
||||
case .direct(let url):
|
||||
return url.absoluteString
|
||||
case .relay(let endpoint):
|
||||
return "\(endpoint.relayBaseURL.host ?? "relay") · \(endpoint.nodeID)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the phone holds a node password for this instance.
|
||||
///
|
||||
/// The two modes mean different things and must not share a string: directly it means "this
|
||||
/// phone has the node's password", through a relay it means "the node's agent has its own
|
||||
/// credential". Showing the first wording in relay mode would tell the user their password is
|
||||
/// on the phone when it deliberately is not.
|
||||
var credentialDescription: String {
|
||||
switch self {
|
||||
case .direct:
|
||||
return "已保存本机密码"
|
||||
case .relay:
|
||||
return "由节点 agent 保存凭据"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
extension InstanceEndpoint: Codable {
|
||||
private enum CodingKeys: String, CodingKey { case kind, url, relay }
|
||||
private enum Kind: String, Codable { case direct, relay }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
switch try container.decode(Kind.self, forKey: .kind) {
|
||||
case .direct:
|
||||
self = .direct(try container.decode(URL.self, forKey: .url))
|
||||
case .relay:
|
||||
self = .relay(try container.decode(RelayEndpoint.self, forKey: .relay))
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .direct(let url):
|
||||
try container.encode(Kind.direct, forKey: .kind)
|
||||
try container.encode(url, forKey: .url)
|
||||
case .relay(let endpoint):
|
||||
try container.encode(Kind.relay, forKey: .kind)
|
||||
try container.encode(endpoint, forKey: .relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RelayEndpoint: Codable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case relayBaseURL, nodeID, agentSigningKey, agentKxKey, deviceToken, deviceID, nodeName
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
relayBaseURL = try container.decode(URL.self, forKey: .relayBaseURL)
|
||||
nodeID = try container.decode(String.self, forKey: .nodeID)
|
||||
agentSigningKey = try container.decode(Data.self, forKey: .agentSigningKey)
|
||||
agentKxKey = try container.decode(Data.self, forKey: .agentKxKey)
|
||||
deviceToken = try container.decode(String.self, forKey: .deviceToken)
|
||||
deviceID = try container.decode(String.self, forKey: .deviceID)
|
||||
nodeName = try container.decodeIfPresent(String.self, forKey: .nodeName)
|
||||
guard agentSigningKey.count == RelayWire.keyBytes, agentKxKey.count == RelayWire.keyBytes else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .agentSigningKey,
|
||||
in: container,
|
||||
debugDescription: "agent key material must be 32 raw bytes"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(relayBaseURL, forKey: .relayBaseURL)
|
||||
try container.encode(nodeID, forKey: .nodeID)
|
||||
try container.encode(agentSigningKey, forKey: .agentSigningKey)
|
||||
try container.encode(agentKxKey, forKey: .agentKxKey)
|
||||
try container.encode(deviceToken, forKey: .deviceToken)
|
||||
try container.encode(deviceID, forKey: .deviceID)
|
||||
try container.encodeIfPresent(nodeName, forKey: .nodeName)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Relay error vocabulary
|
||||
|
||||
/// Every code the relay can return, mirroring `relay/src/errors.js`.
|
||||
///
|
||||
/// The console's `ProblemDetails.code` is an unconstrained string in the OpenAPI document, so this
|
||||
/// is a new vocabulary rather than a reuse of an existing one. It has to be enumerated in one place
|
||||
/// in the first version, because three implementations writing their own strings is exactly how
|
||||
/// cross-tier debugging stops working: the phone shows a code, the relay logs a code, and nobody can
|
||||
/// match them.
|
||||
public enum RelayErrorCode: String, CaseIterable, Sendable {
|
||||
case unauthorized = "relay_unauthorized"
|
||||
case nodeUnknown = "relay_node_unknown"
|
||||
case agentOffline = "relay_agent_offline"
|
||||
case mailboxFull = "relay_mailbox_full"
|
||||
case agentTimeout = "relay_agent_timeout"
|
||||
case badEnvelope = "relay_bad_envelope"
|
||||
case payloadTooLarge = "relay_payload_too_large"
|
||||
case rateLimited = "relay_rate_limited"
|
||||
case internalError = "relay_internal"
|
||||
case sessionExpired = "relay_session_expired"
|
||||
case badChallenge = "relay_bad_challenge"
|
||||
}
|
||||
|
||||
/// The agent's own classification, carried inside the ciphertext as `InnerResponse.outcome`.
|
||||
///
|
||||
/// Separate from `RelayErrorCode` because the two arrive through different channels and mean
|
||||
/// different things: a relay code is "the relay could not deliver this", an agent outcome is "the
|
||||
/// node received it and refused it". Collapsing them into one enum would lose the distinction the
|
||||
/// user needs, which is whether retrying can help.
|
||||
public enum AgentOutcome: String, CaseIterable, Sendable {
|
||||
case ok
|
||||
case badEnvelope = "bad_envelope"
|
||||
case badSignature = "bad_signature"
|
||||
case unknownDevice = "unknown_device"
|
||||
case expired
|
||||
case replay
|
||||
case policyDenied = "policy_denied"
|
||||
case confirmationMissing = "confirmation_missing"
|
||||
case confirmationMismatch = "confirmation_mismatch"
|
||||
case rateLimited = "rate_limited"
|
||||
case payloadTooLarge = "payload_too_large"
|
||||
case upstreamError = "upstream_error"
|
||||
case nodeAuthFailed = "node_auth_failed"
|
||||
}
|
||||
|
||||
/// Freshness of a snapshot, mirroring `FRESHNESS_STATUSES` in `packages/contracts/src/instances.ts`.
|
||||
///
|
||||
/// The four values are the existing vocabulary rather than new names. Without it a relay instance
|
||||
/// cannot express that its "online" reading is forty seconds old, which in a two-hop architecture is
|
||||
/// a substantive misstatement rather than a cosmetic one.
|
||||
public enum SnapshotFreshness: String, CaseIterable, Sendable {
|
||||
case fresh
|
||||
case stale
|
||||
case expired
|
||||
case unknown
|
||||
|
||||
/// Thresholds, chosen to line up with what the UI already implies: a direct probe is fresh for
|
||||
/// as long as a user would call the screen current, and anything past the relay's own request
|
||||
/// deadline is expired rather than merely old.
|
||||
public static func classify(age: TimeInterval?) -> SnapshotFreshness {
|
||||
guard let age, age >= 0 else { return .unknown }
|
||||
switch age {
|
||||
case ..<15: return .fresh
|
||||
case ..<90: return .stale
|
||||
default: return .expired
|
||||
}
|
||||
}
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .fresh: return "刚刚"
|
||||
case .stale: return "稍旧"
|
||||
case .expired: return "已过期"
|
||||
case .unknown: return "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user