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:
2026-07-04 21:05:01 -07:00
co-authored by Claude Fable 5
parent b587702ff5
commit 3e25ef9559
3 changed files with 87 additions and 16 deletions
@@ -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)
@@ -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)