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 <[email protected]>
This commit is contained in:
@@ -83,6 +83,9 @@ final class HostConnection {
|
|||||||
private enum TransportAttempt {
|
private enum TransportAttempt {
|
||||||
case lan(NWEndpoint)
|
case lan(NWEndpoint)
|
||||||
case tailnet(host: String, port: UInt16)
|
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 {
|
private struct ConnectPlan {
|
||||||
@@ -118,14 +121,11 @@ final class HostConnection {
|
|||||||
fail("This pairing code needs a newer version of Nucleic Remote.")
|
fail("This pairing code needs a newer version of Nucleic Remote.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard hint != .relay else {
|
|
||||||
fail("Relay connections aren't supported yet.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let candidates = buildCandidates(
|
let candidates = buildCandidates(
|
||||||
fingerprint: payload.hostStaticKey.fingerprintHex,
|
fingerprint: payload.hostStaticKey.fingerprintHex,
|
||||||
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
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 {
|
guard !candidates.isEmpty else {
|
||||||
fail(hint == .tailnet && !TailnetSupport.isBuiltIn
|
fail(hint == .tailnet && !TailnetSupport.isBuiltIn
|
||||||
? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in"
|
? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in"
|
||||||
@@ -146,14 +146,11 @@ final class HostConnection {
|
|||||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||||
hostName = host.hostName
|
hostName = host.hostName
|
||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
guard host.transportHint != .relay else {
|
|
||||||
fail("Relay connections aren't supported yet.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let candidates = buildCandidates(
|
let candidates = buildCandidates(
|
||||||
fingerprint: host.fingerprint,
|
fingerprint: host.fingerprint,
|
||||||
lanHost: host.lanHost, lanPort: host.lanPort,
|
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 {
|
guard !candidates.isEmpty else {
|
||||||
connectivity = .hostOffline
|
connectivity = .hostOffline
|
||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
@@ -176,7 +173,8 @@ final class HostConnection {
|
|||||||
|
|
||||||
private func buildCandidates(
|
private func buildCandidates(
|
||||||
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
||||||
tailnet: (host: String?, port: UInt16?)?
|
tailnet: (host: String?, port: UInt16?)?,
|
||||||
|
relay: (membershipToken: String?, url: String?)? = nil
|
||||||
) -> [TransportAttempt] {
|
) -> [TransportAttempt] {
|
||||||
var candidates: [TransportAttempt] = []
|
var candidates: [TransportAttempt] = []
|
||||||
if let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
|
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 {
|
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
|
||||||
candidates.append(.tailnet(host: host, port: port))
|
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
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,13 +224,39 @@ final class HostConnection {
|
|||||||
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
|
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
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 tryNextCandidate() { return }
|
||||||
if isPairing {
|
if isPairing {
|
||||||
fail(error.localizedDescription)
|
fail(error.localizedDescription)
|
||||||
@@ -347,7 +374,10 @@ final class HostConnection {
|
|||||||
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
||||||
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
||||||
transport: payload.transport, tailnetHost: payload.tailnetHost,
|
transport: payload.transport, tailnetHost: payload.tailnetHost,
|
||||||
tailnetPort: payload.tailnetPort))
|
tailnetPort: payload.tailnetPort,
|
||||||
|
relayRoomID: payload.relayRoomID,
|
||||||
|
relayMembershipToken: payload.relayMembershipToken,
|
||||||
|
relayURL: payload.relayURL))
|
||||||
}
|
}
|
||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
send(.listSessions)
|
send(.listSessions)
|
||||||
@@ -394,6 +424,21 @@ final class HostConnection {
|
|||||||
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
|
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
|
||||||
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
|
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
|
||||||
break
|
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):
|
case .wireError(let error):
|
||||||
if error.code == .channelMismatch {
|
if error.code == .channelMismatch {
|
||||||
connectivity = .failed(error.message)
|
connectivity = .failed(error.message)
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ struct PairedHost: Codable, Equatable {
|
|||||||
var transport: String?
|
var transport: String?
|
||||||
var tailnetHost: String?
|
var tailnetHost: String?
|
||||||
var tailnetPort: UInt16?
|
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 }
|
var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
|
||||||
}
|
}
|
||||||
@@ -105,6 +112,16 @@ enum IdentityStore {
|
|||||||
savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID })
|
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]) {
|
private static func savePairedHosts(_ hosts: [PairedHost]) {
|
||||||
if let data = try? JSONEncoder().encode(hosts) {
|
if let data = try? JSONEncoder().encode(hosts) {
|
||||||
UserDefaults.standard.set(data, forKey: pairedHostsKey)
|
UserDefaults.standard.set(data, forKey: pairedHostsKey)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
// What this pairing can dial — the live transport is in Status above.
|
// What this pairing can dial — the live transport is in Status above.
|
||||||
if let host = IdentityStore.loadPairedHost() {
|
if let host = IdentityStore.loadPairedHost() {
|
||||||
LabeledContent("Transports", value: host.tailnetHost != nil ? "LAN + Tailnet" : "LAN")
|
LabeledContent("Transports", value: Self.transportsLabel(host))
|
||||||
}
|
}
|
||||||
Button("Reconnect") { store.reconnect() }
|
Button("Reconnect") { store.reconnect() }
|
||||||
.disabled(!store.isPaired)
|
.disabled(!store.isPaired)
|
||||||
@@ -175,7 +175,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} 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")
|
.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.
|
/// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.
|
||||||
|
|||||||
Reference in New Issue
Block a user