From 3e25ef95590bea3f398aa5a824851656814f7f6d Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 21:05:01 -0700 Subject: [PATCH] feat(relay): wire the live Nucleic Private Relay into the desktop + iOS apps (mesh P2 complete) The relay Worker (nucleic-edge at relay.nucleic.blakeslee.xyz) is deployed, so land the formerly deploy-gated client side of the data path: - NucleicProtocol/Sync/RelayTransport.swift: RelayAPI (one base URL for REST + WS, membership -> connection token trade), RelayWebSocket (ordered sends, ping keepalive, ping-confirmed connect), RelayFrameChannel (client leg, WireFraming inside WS binary, presence fail-fast when the room has no host), RelayPresence. - NucleicCore/Sync/RelayAccess.swift: X25519 PoP enrollment (RelayEnrollment), room credential in the login Keychain (separate from the push credential), membership minting with re-enroll-on-401. - NucleicCore/Sync/RelayListener.swift: host SyncListener demuxing the room socket into per-tag virtual FrameChannels via RelayEnvelope; presence-driven reaping; backoff redial; injectable RelayRoomSocket seam for tests. - Wire: additive HostMsg.relayMembership(WireRelayMembership) pushed after every hello (SyncHost.register -> AppStore mint) so devices paired before the relay adopt it and the ~90-day token refreshes on each connect; the pairing QR also carries a bootstrap membership so first contact can ride the relay. Old clients ignore the unknown tag. - AppStore: .relay joins the listener composite behind the Connection-methods checkbox (failure degrades to a status row), advertises relayRoomID in PeerAddresses, mints the QR bootstrap in beginPairing. - Desktop UI: the Nucleic Private Relay toggle is enabled (was "coming soon"); the LAN-only banner offers it alongside Tailnet. - iOS: relay is the last dial candidate in HostConnection pair + reconnect (10s handshake watchdog); PairedHost persists relayRoomID/relayMembershipToken/relayURL; the relayMembership push updates the registry in place; Settings shows Relay in Transports. Tests: RelayTransportTests, RelayListenerTests, SyncHostTests relay push + QR bootstrap. Full suite green (783 core + 113 protocol + 2 new); iOS simulator build succeeds. Live smoke test against the deployed Worker passed end-to-end (PoP enroll, both token tiers, two-socket frame round-trip through the Room DO with correct envelope tags). Known limits: host revoke-on-unpair not wired (endpoint is admin-only); PeerClient (Mac<->Mac) doesn't dial the relay yet. Co-Authored-By: Claude Fable 5 --- .../NucleicRemote/Models/HostConnection.swift | 73 +++++++++++++++---- .../NucleicRemote/Models/IdentityStore.swift | 17 +++++ .../NucleicRemote/Views/SettingsView.swift | 13 +++- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 8e13db1..967cbbf 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -83,6 +83,9 @@ final class HostConnection { private enum TransportAttempt { case lan(NWEndpoint) case tailnet(host: String, port: UInt16) + /// Nucleic Private Relay (mesh P2) — always the last candidate: works from anywhere, + /// but a direct path beats a brokered one when both exist. + case relay(base: URL, membershipToken: String) } private struct ConnectPlan { @@ -118,14 +121,11 @@ final class HostConnection { fail("This pairing code needs a newer version of Nucleic Remote.") return } - guard hint != .relay else { - fail("Relay connections aren't supported yet.") - return - } let candidates = buildCandidates( fingerprint: payload.hostStaticKey.fingerprintHex, lanHost: payload.lanHost, lanPort: payload.lanPort, - tailnet: hint == .tailnet ? (payload.tailnetHost, payload.tailnetPort) : nil) + tailnet: hint == .tailnet ? (payload.tailnetHost, payload.tailnetPort) : nil, + relay: (payload.relayMembershipToken, payload.relayURL)) guard !candidates.isEmpty else { fail(hint == .tailnet && !TailnetSupport.isBuiltIn ? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in" @@ -146,14 +146,11 @@ final class HostConnection { connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting hostName = host.hostName callbacks.didUpdate() - guard host.transportHint != .relay else { - fail("Relay connections aren't supported yet.") - return - } let candidates = buildCandidates( fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort, - tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil) + tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil, + relay: (host.relayMembershipToken, host.relayURL)) guard !candidates.isEmpty else { connectivity = .hostOffline callbacks.didUpdate() @@ -176,7 +173,8 @@ final class HostConnection { private func buildCandidates( fingerprint: String?, lanHost: String?, lanPort: UInt16?, - tailnet: (host: String?, port: UInt16?)? + tailnet: (host: String?, port: UInt16?)?, + relay: (membershipToken: String?, url: String?)? = nil ) -> [TransportAttempt] { var candidates: [TransportAttempt] = [] if let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) { @@ -185,6 +183,9 @@ final class HostConnection { if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn { candidates.append(.tailnet(host: host, port: port)) } + if let relay, let token = relay.membershipToken, !token.isEmpty { + candidates.append(.relay(base: RelayAPI.baseURL(relay.url), membershipToken: token)) + } return candidates } @@ -223,13 +224,39 @@ final class HostConnection { mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload) } catch { guard !Task.isCancelled else { return } - self.tailnetAttemptFailed(error, isPairing: plan.pairingPayload != nil) + self.asyncAttemptFailed(error, isPairing: plan.pairingPayload != nil) + } + } + case .relay(let base, let membershipToken): + activeTransport = .relay + connectTask = Task { [weak self] in + guard let self else { return } + do { + let channel = try await RelayFrameChannel.dial( + base: base, membershipToken: membershipToken) + guard !Task.isCancelled else { channel.close(); return } + // A room with no live host swallows frames silently until presence says + // otherwise — bound the handshake like the LAN path does. + self.lanConnectTimeout?.cancel() + self.lanConnectTimeout = Task { [weak self, weak channel] in + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled, let self, !self.connectivity.isLive else { return } + channel?.close() + } + self.startClient( + channel: channel, hostStaticKey: plan.hostStaticKey, + mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload) + } catch { + guard !Task.isCancelled else { return } + self.asyncAttemptFailed(error, isPairing: plan.pairingPayload != nil) } } } } - private func tailnetAttemptFailed(_ error: Error, isPairing: Bool) { + /// A candidate that dials asynchronously (tailnet, relay) failed before producing a + /// channel — fall through to the next candidate or schedule a retry. + private func asyncAttemptFailed(_ error: Error, isPairing: Bool) { if tryNextCandidate() { return } if isPairing { fail(error.localizedDescription) @@ -347,7 +374,10 @@ final class HostConnection { hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex, lanHost: payload.lanHost, lanPort: payload.lanPort, transport: payload.transport, tailnetHost: payload.tailnetHost, - tailnetPort: payload.tailnetPort)) + tailnetPort: payload.tailnetPort, + relayRoomID: payload.relayRoomID, + relayMembershipToken: payload.relayMembershipToken, + relayURL: payload.relayURL)) } callbacks.didUpdate() send(.listSessions) @@ -394,6 +424,21 @@ final class HostConnection { case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck: // Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one. break + case .relayMembership(let membership): + // The host issued/refreshed this device's relay credential (mesh P2). Persist it + // in place (no reordering — this can arrive from a non-active Mac) so the relay + // is a dial candidate on the next reconnect; also patch the in-memory pin so an + // imminent retry uses the fresh token without a registry round-trip. + IdentityStore.updatePairedHost(id: hostID) { + $0.relayRoomID = membership.roomID + $0.relayMembershipToken = membership.token + $0.relayURL = membership.url + } + if pinnedHost?.fingerprint == hostID { + pinnedHost?.relayRoomID = membership.roomID + pinnedHost?.relayMembershipToken = membership.token + pinnedHost?.relayURL = membership.url + } case .wireError(let error): if error.code == .channelMismatch { connectivity = .failed(error.message) diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index d7493fe..ecfbe49 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -21,6 +21,13 @@ struct PairedHost: Codable, Equatable { var transport: String? var tailnetHost: String? var tailnetPort: UInt16? + /// Nucleic Private Relay bootstrap (mesh P2): the host's room, this device's membership + /// token, and an optional base-URL override. Set from the pairing QR and refreshed by + /// the host's `relayMembership` push on every connect; nil while the host has the relay + /// method off (records predating the relay decode them as nil). + var relayRoomID: String? + var relayMembershipToken: String? + var relayURL: String? var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan } } @@ -105,6 +112,16 @@ enum IdentityStore { savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID }) } + /// Mutate one host's record **in place**, preserving registry order — unlike + /// `upsertPairedHost`, this must not make the host active (it backs background + /// refreshes like the relay-membership push, which can arrive from a non-active Mac). + static func updatePairedHost(id hostID: String, mutate: (inout PairedHost) -> Void) { + var hosts = pairedHosts() + guard let index = hosts.firstIndex(where: { $0.fingerprint == hostID }) else { return } + mutate(&hosts[index]) + savePairedHosts(hosts) + } + private static func savePairedHosts(_ hosts: [PairedHost]) { if let data = try? JSONEncoder().encode(hosts) { UserDefaults.standard.set(data, forKey: pairedHostsKey) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index baf9980..8840575 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -60,7 +60,7 @@ struct SettingsView: View { } // What this pairing can dial — the live transport is in Status above. if let host = IdentityStore.loadPairedHost() { - LabeledContent("Transports", value: host.tailnetHost != nil ? "LAN + Tailnet" : "LAN") + LabeledContent("Transports", value: Self.transportsLabel(host)) } Button("Reconnect") { store.reconnect() } .disabled(!store.isPaired) @@ -175,7 +175,7 @@ struct SettingsView: View { } } } footer: { - Text("Code and diffs are end-to-end encrypted. A relay (later) can't read them.") + Text("Code and diffs are end-to-end encrypted. The Nucleic Private Relay can't read them.") } } .navigationTitle("Settings") @@ -193,6 +193,15 @@ struct SettingsView: View { } } } + + /// Every path this pairing can dial: LAN always, plus what the host advertised — a + /// tailnet hint from the QR and/or a relay membership (QR or the host's later push). + private static func transportsLabel(_ host: PairedHost) -> String { + var parts = ["LAN"] + if host.tailnetHost != nil { parts.append("Tailnet") } + if host.relayMembershipToken?.isEmpty == false { parts.append("Relay") } + return parts.joined(separator: " + ") + } } /// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.