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 {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user