Merge nucleic/rustic-maple-marten into dev
This commit is contained in:
@@ -647,10 +647,11 @@ private struct ConnectionTransportSection: View {
|
||||
/// off or the method is unchecked.
|
||||
@ViewBuilder
|
||||
private func statusDot(_ hint: SyncTransportHint) -> some View {
|
||||
if store.syncStartInFlight, enabled.contains(hint) {
|
||||
Text("Connecting…")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else if store.syncRunning, enabled.contains(hint) {
|
||||
// `syncRunning` first: the post-commit tail of a (re)start — paired-device refresh,
|
||||
// peer-client bring-up — keeps `syncStartInFlight` true for a while after the server
|
||||
// is already listening, and that window must read as the committed state, not
|
||||
// "Connecting…".
|
||||
if store.syncRunning, enabled.contains(hint) {
|
||||
let active = store.syncActiveTransports.contains(hint)
|
||||
Circle()
|
||||
.fill(active ? Color.green : Color.orange)
|
||||
@@ -658,6 +659,9 @@ private struct ConnectionTransportSection: View {
|
||||
Text(active ? "Connected" : "Not reachable")
|
||||
.font(.caption)
|
||||
.foregroundStyle(active ? Color.green : Color.orange)
|
||||
} else if store.syncStartInFlight, enabled.contains(hint) {
|
||||
Text("Connecting…")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7577,7 +7577,15 @@ public final class AppStore: ConflictArbiter {
|
||||
/// node login for seconds, and a toggle-off or method change during that window is
|
||||
/// honored at the commit point).
|
||||
public func startSyncServer() async {
|
||||
guard syncHost == nil, !syncStartInFlight else { return }
|
||||
guard syncHost == nil, !syncStartInFlight else {
|
||||
// A follow-up start while one is already in flight supersedes any stop that raced
|
||||
// in between (restartIfRunning's stop→start pair landing mid-start): the in-flight
|
||||
// start re-reads the method set at its commit point, so clearing the stop flag
|
||||
// lets it commit with the new settings instead of abandoning — which previously
|
||||
// left the server off while every toggle said on.
|
||||
if syncStartInFlight { syncStopRequested = false }
|
||||
return
|
||||
}
|
||||
syncStartInFlight = true
|
||||
defer {
|
||||
syncStartInFlight = false
|
||||
@@ -7609,6 +7617,17 @@ public final class AppStore: ConflictArbiter {
|
||||
var health: [SyncTransportHint: String] = [:]
|
||||
var children: [(transport: SyncTransportHint, listener: any SyncListener)] = []
|
||||
|
||||
// Kick the relay bring-up off first so its network legs (enroll + token mints +
|
||||
// the room WebSocket, each individually bounded) overlap the tailnet/LAN bring-up
|
||||
// below instead of serializing after them — an unreachable relay must cost the
|
||||
// start at most its own deadline, never hold LAN/tailnet hostage.
|
||||
var relayStart: Task<(RelayAccess, RelayListener), any Error>?
|
||||
if enabled.contains(.relay) {
|
||||
relayStart = Task { [identity] in
|
||||
try await self.startRelayListener(identity: identity)
|
||||
}
|
||||
}
|
||||
|
||||
if enabled.contains(.tailnet) {
|
||||
do {
|
||||
let tailnetListener = try await startTailnetListener()
|
||||
@@ -7635,9 +7654,9 @@ public final class AppStore: ConflictArbiter {
|
||||
children.append((.lan, lanListener))
|
||||
}
|
||||
#endif
|
||||
if enabled.contains(.relay) {
|
||||
if let relayStart {
|
||||
do {
|
||||
let (access, relayListener) = try await startRelayListener(identity: identity)
|
||||
let (access, relayListener) = try await relayStart.value
|
||||
relayAccess = access
|
||||
syncRelayListener = relayListener
|
||||
children.append((.relay, relayListener))
|
||||
@@ -8710,16 +8729,37 @@ extension AppStore: SyncHostBridge {
|
||||
}
|
||||
|
||||
/// Push the roster to all connected members (inbound clients + outbound peer Macs), but only
|
||||
/// when it differs from the last one we sent.
|
||||
/// when it *meaningfully* differs from the last one we sent. The comparison strips freshness
|
||||
/// bookkeeping (`lastSeenAt`, `addresses.updatedAt`): every hello anywhere bumps those, and
|
||||
/// deduping on the full value re-pushed the roster mesh-wide per hello — each wave triggering
|
||||
/// the receivers' own merge → gossip → merge echo across members.
|
||||
private func gossipRosterIfChanged() async {
|
||||
guard let syncHost else { return }
|
||||
let roster = await meshRoster()
|
||||
guard roster != lastGossipedRoster else { return }
|
||||
lastGossipedRoster = roster
|
||||
let signature = Self.rosterSignature(roster)
|
||||
guard signature != lastGossipedRoster else { return }
|
||||
lastGossipedRoster = signature
|
||||
await syncHost.broadcastMeshRoster(roster)
|
||||
await peerClient?.pushRoster(roster)
|
||||
}
|
||||
|
||||
/// The roster with its freshness metadata zeroed — what `gossipRosterIfChanged` dedups on.
|
||||
private static func rosterSignature(_ push: MeshRosterPush) -> MeshRosterPush {
|
||||
MeshRosterPush(
|
||||
members: push.members.map { m in
|
||||
MeshMember(
|
||||
deviceID: m.deviceID, label: m.label, kind: m.kind,
|
||||
capabilities: m.capabilities, staticPublicKey: m.staticPublicKey,
|
||||
addresses: m.addresses.map {
|
||||
PeerAddresses(
|
||||
lanHint: $0.lanHint, tailnet: $0.tailnet,
|
||||
relayRoomID: $0.relayRoomID, updatedAt: nil)
|
||||
},
|
||||
pairedAt: m.pairedAt, lastSeenAt: nil)
|
||||
},
|
||||
tombstones: push.tombstones)
|
||||
}
|
||||
|
||||
// MARK: Session transfer — destination side (mesh P5)
|
||||
|
||||
/// The lazily-built importer. One per host, sharing this Mac's store + worktree manager, with
|
||||
|
||||
@@ -354,6 +354,14 @@ actor ConnectionHandler {
|
||||
while let frame = await nextFrame() {
|
||||
let msg: ClientMsg
|
||||
do { msg = try open(frame) }
|
||||
catch where SecureSession.isAuthenticationFailure(error) {
|
||||
// The sender doesn't share this session's keys. On the relay a reconnecting
|
||||
// device keeps its routing tag, so its *fresh handshake* lands on this stale
|
||||
// handler — and every retry refreshes the liveness watchdog, so without this
|
||||
// the zombie eats the device's reconnects forever. Tear down: the close frees
|
||||
// the demux slot and the next inbound frame opens a fresh channel + handshake.
|
||||
throw SyncTransportError.connectionClosed
|
||||
}
|
||||
catch { send(.error(WireError(code: .malformed, message: "undecodable frame"))); continue }
|
||||
await dispatch(msg)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,19 @@ public enum MeshRosterMerge {
|
||||
upd.kind = m.kind
|
||||
upd.capabilities = m.capabilities
|
||||
upd.addresses = fresher(existing.addresses, m.addresses)
|
||||
// "Changed" is what the caller reconciles dial loops and re-gossips on — so it
|
||||
// must mean something actionable moved: identity metadata or a dialable address
|
||||
// FIELD. Freshness bookkeeping alone (a lastSeenAt bump — which every hello
|
||||
// anywhere in the mesh produces — or an addresses.updatedAt restamp with
|
||||
// identical fields) is persisted silently below; counting it re-gossiped the
|
||||
// roster mesh-wide per hello and echoed between members indefinitely.
|
||||
let meaningful = upd.label != existing.label
|
||||
|| upd.kind != existing.kind
|
||||
|| upd.capabilities != existing.capabilities
|
||||
|| !dialFieldsEqual(upd.addresses, existing.addresses)
|
||||
upd.lastSeenAt = later(existing.lastSeenAt, m.lastSeenAt)
|
||||
if upd != existing { await store.upsert(upd); changed = true }
|
||||
if upd != existing { await store.upsert(upd) }
|
||||
if meaningful { changed = true }
|
||||
} else {
|
||||
// Unknown member vouched for by a trusted introducer → auto-pin (control scope,
|
||||
// matching a directly-paired Mac). Preserve the origin `pairedAt` so the tombstone
|
||||
@@ -98,4 +109,10 @@ public enum MeshRosterMerge {
|
||||
default: return a ?? b
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two address sets agree on every *dialable* field — `updatedAt` deliberately
|
||||
/// excluded (a restamp with identical endpoints isn't a change anyone can act on).
|
||||
static func dialFieldsEqual(_ a: PeerAddresses?, _ b: PeerAddresses?) -> Bool {
|
||||
a?.lanHint == b?.lanHint && a?.tailnet == b?.tailnet && a?.relayRoomID == b?.relayRoomID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,10 +594,10 @@ public actor PeerClient {
|
||||
}
|
||||
let endpoints = Self.endpoints(fromPayload: payload)
|
||||
guard !endpoints.isEmpty else {
|
||||
// Distinguish "no hints at all" from "hints we can't dial yet" (relay-only).
|
||||
if payload.transportHint == nil || payload.relayRoomID != nil {
|
||||
throw PeerPairError.unsupportedTransport
|
||||
}
|
||||
// Relay codes are dialable now, so an empty set means either a transport this
|
||||
// build doesn't know at all (unknown future hint) or a code with no usable hints
|
||||
// (e.g. a relay room advertised without a membership token).
|
||||
if payload.transportHint == nil { throw PeerPairError.unsupportedTransport }
|
||||
throw PeerPairError.noEndpoints
|
||||
}
|
||||
|
||||
@@ -1049,7 +1049,9 @@ public actor PeerClient {
|
||||
return result
|
||||
}
|
||||
|
||||
/// Dial candidates from a pairing payload's explicit hints (ports included).
|
||||
/// Dial candidates from a pairing payload's explicit hints (ports included). Relay last,
|
||||
/// mirroring the reconnect ordering — direct paths win, but a code pasted across networks
|
||||
/// (no shared LAN, no tailnet) can still complete the pairing through the host's room.
|
||||
static func endpoints(fromPayload payload: PairingPayload) -> [PeerEndpoint] {
|
||||
var result: [PeerEndpoint] = []
|
||||
if let host = payload.lanHost, let port = payload.lanPort {
|
||||
@@ -1058,6 +1060,9 @@ public actor PeerClient {
|
||||
if let host = payload.tailnetHost, let port = payload.tailnetPort {
|
||||
result.append(.tailnet(host: host, port: port))
|
||||
}
|
||||
if let token = payload.relayMembershipToken, !token.isEmpty {
|
||||
result.append(.relay(baseURLString: payload.relayURL, membershipToken: token))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,10 @@ public actor RelayAccess {
|
||||
return try await mint(deviceID: deviceID, role: role, bearer: credential.bearer)
|
||||
} catch Error.mintFailed(let why) where why.contains("401") {
|
||||
self.credential = nil
|
||||
let fresh = try await enroll()
|
||||
// Bypass the enrollment backoff: the 401 itself just proved the relay reachable —
|
||||
// the credential lapsed server-side, and gating the re-enroll behind a stale
|
||||
// "unreachable" window would fail this mint for no reason.
|
||||
let fresh = try await enroll(force: true)
|
||||
return try await mint(deviceID: deviceID, role: role, bearer: fresh.bearer)
|
||||
}
|
||||
}
|
||||
@@ -89,9 +92,9 @@ public actor RelayAccess {
|
||||
return try await enroll()
|
||||
}
|
||||
|
||||
private func enroll() async throws -> RelayRoomCredential {
|
||||
private func enroll(force: Bool = false) async throws -> RelayRoomCredential {
|
||||
let now = Date()
|
||||
if let last = lastEnrollAttempt, now.timeIntervalSince(last) < Self.enrollRetryInterval {
|
||||
if !force, let last = lastEnrollAttempt, now.timeIntervalSince(last) < Self.enrollRetryInterval {
|
||||
throw Error.enrollFailed("relay unreachable (retrying shortly)")
|
||||
}
|
||||
lastEnrollAttempt = now
|
||||
@@ -133,9 +136,16 @@ public actor RelayAccess {
|
||||
request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: ["deviceId": deviceID, "role": role])
|
||||
guard let (data, response) = try? await session.data(for: request),
|
||||
let http = response as? HTTPURLResponse
|
||||
else { throw Error.mintFailed("relay unreachable") }
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
// Carry the specific transport failure (DNS, TLS, timeout) like `postJSON` does —
|
||||
// "relay unreachable" alone made the Settings status row undiagnosable.
|
||||
throw Error.mintFailed("transport error: \(error)")
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else { throw Error.mintFailed("non-HTTP response") }
|
||||
guard http.statusCode == 200 else { throw Error.mintFailed("status \(http.statusCode)") }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = json["token"] as? String
|
||||
|
||||
@@ -136,10 +136,14 @@ public final class RelayListener: SyncListener, @unchecked Sendable {
|
||||
private func socketDied() {
|
||||
lock.lock()
|
||||
let wasStopped = stopped
|
||||
let dead = self.socket
|
||||
self.socket = nil
|
||||
let open = channels.values
|
||||
channels.removeAll()
|
||||
lock.unlock()
|
||||
// Close the dead socket explicitly — its stream already finished, but its ping/writer
|
||||
// tasks (and the URLSession task they retain) only wind down on close().
|
||||
dead?.close()
|
||||
for channel in open { channel.finish() }
|
||||
guard !wasStopped else { return }
|
||||
|
||||
@@ -181,10 +185,12 @@ public final class RelayListener: SyncListener, @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// A `ConnectionHandler` closed its channel — drop the demux slot so a future frame from
|
||||
/// that tag opens a fresh channel (and a fresh handshake).
|
||||
fileprivate func channelClosed(tag: Data) {
|
||||
/// that tag opens a fresh channel (and a fresh handshake). Identity-checked: generations
|
||||
/// of the same device share one tag, so a *superseded* handler's deferred close (the
|
||||
/// `SyncHost` dedup grace, the liveness reaper) must not evict its successor's live slot.
|
||||
fileprivate func channelClosed(tag: Data, channel: RelayRoomChannel) {
|
||||
lock.lock()
|
||||
channels.removeValue(forKey: tag)
|
||||
if channels[tag] === channel { channels.removeValue(forKey: tag) }
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -219,7 +225,7 @@ final class RelayRoomChannel: FrameChannel, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func close() {
|
||||
listener?.channelClosed(tag: tag)
|
||||
listener?.channelClosed(tag: tag, channel: self)
|
||||
finish()
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,16 @@ public final class SecureSession {
|
||||
let plaintext = try recv.decrypt(ad: Data(), ciphertext: frame)
|
||||
return try CBORDecoder().decode(T.self, from: plaintext)
|
||||
}
|
||||
|
||||
/// Whether an `open(_:from:)` failure means the sender does NOT share this session's keys
|
||||
/// (AEAD/authentication failure) — as opposed to a frame that decrypted fine but couldn't
|
||||
/// be decoded (an unknown/newer message tag). Callers treat the former as fatal: the bytes
|
||||
/// come from a different connection generation (e.g. a device redialing through the relay,
|
||||
/// whose routing tag is stable across its reconnects) or a corrupt stream, and no future
|
||||
/// frame from that sender will ever decrypt. The latter stays skippable (forward compat).
|
||||
public static func isAuthenticationFailure(_ error: Error) -> Bool {
|
||||
error is NoiseError
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pairing QR payload (SYNC §4.2)
|
||||
|
||||
@@ -40,6 +40,7 @@ public enum RelayAPI {
|
||||
case membershipRejected(status: Int)
|
||||
case badResponse
|
||||
case socketClosed
|
||||
case connectTimedOut
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -49,6 +50,7 @@ public enum RelayAPI {
|
||||
: "The relay didn't accept this device's credential (\(status)) — reconnect over LAN once to refresh it."
|
||||
case .badResponse: "The relay returned an unexpected response."
|
||||
case .socketClosed: "The relay connection closed."
|
||||
case .connectTimedOut: "The relay didn't answer in time."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +111,43 @@ public struct RelayPresence: Sendable, Equatable, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settles a `connect` exactly once. On Linux the upgrader's completion races the channel's
|
||||
/// close (a rejected token never sends 101; the edge just closes) and completing an
|
||||
/// `EventLoopPromise` twice traps; on Darwin the ping confirmation races the connect
|
||||
/// deadline. Shared by both backends.
|
||||
final class OnceResult: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var result: Result<Void, Error>?
|
||||
private var waiter: CheckedContinuation<Void, Error>?
|
||||
|
||||
func succeed() { settle(.success(())) }
|
||||
func fail(_ error: Error) { settle(.failure(error)) }
|
||||
|
||||
private func settle(_ outcome: Result<Void, Error>) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard result == nil else { return }
|
||||
result = outcome
|
||||
if let waiter {
|
||||
self.waiter = nil
|
||||
waiter.resume(with: outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func value() async throws {
|
||||
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in
|
||||
lock.lock()
|
||||
if let result {
|
||||
lock.unlock()
|
||||
c.resume(with: result)
|
||||
return
|
||||
}
|
||||
waiter = c
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the host's room listener needs from its socket — seam for tests (an in-memory fake
|
||||
/// drives the demux without a network). `RelayWebSocket` is the real implementation.
|
||||
public protocol RelayRoomSocket: AnyObject, Sendable {
|
||||
@@ -144,16 +183,34 @@ public final class RelayWebSocket: RelayRoomSocket, @unchecked Sendable {
|
||||
/// reconnect budget. (Cloudflare's edge answers protocol pings without waking the DO.)
|
||||
private static let pingInterval: Duration = .seconds(25)
|
||||
|
||||
public static func connect(url: URL, session: URLSession = .shared) async throws -> RelayWebSocket {
|
||||
public static func connect(
|
||||
url: URL, session: URLSession = .shared, timeout: Duration = .seconds(15)
|
||||
) async throws -> RelayWebSocket {
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = WireFraming.maxFrameSize + 64
|
||||
task.resume()
|
||||
// A rejected upgrade (bad/expired token) surfaces as this ping failing.
|
||||
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Swift.Error>) in
|
||||
task.sendPing { error in
|
||||
if let error { c.resume(throwing: error) } else { c.resume() }
|
||||
}
|
||||
// A rejected upgrade (bad/expired token) surfaces as this ping failing; a blackholed
|
||||
// socket (middlebox that accepts the upgrade but drops traffic) surfaces as *nothing*
|
||||
// — URLSession imposes no post-handshake deadline on the pong — so the wait is raced
|
||||
// against a hard timeout. Without it a single wedged connect suspends the caller
|
||||
// forever: the sync-server start path, and every dial loop above this, have no
|
||||
// deadline of their own until the socket is returned.
|
||||
let settled = OnceResult()
|
||||
let deadline = Task {
|
||||
try? await Task.sleep(for: timeout)
|
||||
settled.fail(RelayAPI.Error.connectTimedOut)
|
||||
}
|
||||
task.sendPing { error in
|
||||
if let error { settled.fail(error) } else { settled.succeed() }
|
||||
}
|
||||
do {
|
||||
try await settled.value()
|
||||
} catch {
|
||||
deadline.cancel()
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
throw error
|
||||
}
|
||||
deadline.cancel()
|
||||
return RelayWebSocket(task: task)
|
||||
}
|
||||
|
||||
@@ -190,7 +247,11 @@ public final class RelayWebSocket: RelayRoomSocket, @unchecked Sendable {
|
||||
pingTask = Task { [weak self, task] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: Self.pingInterval)
|
||||
guard !Task.isCancelled else { break }
|
||||
// Exit when the socket is gone, not just when cancelled: a RelayWebSocket
|
||||
// dropped without close() (socket died, owner deallocated) would otherwise
|
||||
// leave this loop pinging the dead URLSession task — which URLSession
|
||||
// retains — every 25s forever, one leaked task set per redial.
|
||||
guard !Task.isCancelled, self != nil else { break }
|
||||
task.sendPing { [weak self] error in
|
||||
if error != nil { self?.close() }
|
||||
}
|
||||
@@ -255,7 +316,9 @@ public final class RelayWebSocket: RelayRoomSocket, @unchecked Sendable {
|
||||
private let inboundContinuation: AsyncStream<Message>.Continuation
|
||||
private var pingTask: Task<Void, Never>?
|
||||
|
||||
public static func connect(url: URL, session: URLSession = .shared) async throws -> RelayWebSocket {
|
||||
public static func connect(
|
||||
url: URL, session: URLSession = .shared, timeout: Duration = .seconds(15)
|
||||
) async throws -> RelayWebSocket {
|
||||
guard let host = url.host else { throw RelayAPI.Error.badResponse }
|
||||
let useTLS = url.scheme == "wss" || url.scheme == "https"
|
||||
let port = url.port ?? (useTLS ? 443 : 80)
|
||||
@@ -310,12 +373,22 @@ public final class RelayWebSocket: RelayRoomSocket, @unchecked Sendable {
|
||||
upgraded.fail(RelayAPI.Error.socketClosed)
|
||||
continuation.finish()
|
||||
}
|
||||
// The close future only fires if the server closes: a non-101 answer over HTTP
|
||||
// keep-alive (NIO's client upgrader forwards it and removes itself without closing),
|
||||
// or a server that accepts TCP and then goes silent, would otherwise park this await
|
||||
// forever — `connectTimeout` above covers only the TCP leg. Race a hard deadline.
|
||||
let deadline = Task {
|
||||
try? await Task.sleep(for: timeout)
|
||||
upgraded.fail(RelayAPI.Error.connectTimedOut)
|
||||
}
|
||||
do {
|
||||
try await upgraded.value()
|
||||
} catch {
|
||||
deadline.cancel()
|
||||
try? await channel.close()
|
||||
throw error
|
||||
}
|
||||
deadline.cancel()
|
||||
return RelayWebSocket(channel: channel, inbound: inbound, continuation: continuation)
|
||||
}
|
||||
|
||||
@@ -373,42 +446,6 @@ public final class RelayWebSocket: RelayRoomSocket, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settles a `connect` exactly once — the upgrader's completion races the channel's close
|
||||
/// (a rejected token never sends 101; the edge just closes), and completing an
|
||||
/// `EventLoopPromise` twice traps.
|
||||
private final class OnceResult: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var result: Result<Void, Error>?
|
||||
private var waiter: CheckedContinuation<Void, Error>?
|
||||
|
||||
func succeed() { settle(.success(())) }
|
||||
func fail(_ error: Error) { settle(.failure(error)) }
|
||||
|
||||
private func settle(_ outcome: Result<Void, Error>) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard result == nil else { return }
|
||||
result = outcome
|
||||
if let waiter {
|
||||
self.waiter = nil
|
||||
waiter.resume(with: outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func value() async throws {
|
||||
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in
|
||||
lock.lock()
|
||||
if let result {
|
||||
lock.unlock()
|
||||
c.resume(with: result)
|
||||
return
|
||||
}
|
||||
waiter = c
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires the HTTP upgrade GET the moment the (TLS) channel is up; the
|
||||
/// `NIOHTTPClientUpgradeHandler` ahead of it stamps the Upgrade/Sec-WebSocket-* headers on.
|
||||
private final class RelayUpgradeRequestSender: ChannelInboundHandler, RemovableChannelHandler,
|
||||
|
||||
@@ -140,6 +140,31 @@ import NucleicProtocol
|
||||
#expect(await store.isTombstoned(selfID) == false) // we never tombstone ourselves
|
||||
}
|
||||
|
||||
/// Freshness bookkeeping alone — a `lastSeenAt` bump (every hello anywhere produces one) or an
|
||||
/// `addresses.updatedAt` restamp with identical dial fields — is persisted silently but reports
|
||||
/// NO change: counting it re-gossiped the roster mesh-wide per hello and echoed between members.
|
||||
@Test func freshnessOnlyUpdateIsPersistedButNotAChange() async throws {
|
||||
let store = InMemoryPairedDeviceStore()
|
||||
let identity = DeviceIdentity()
|
||||
await store.upsert(PairedDevice(
|
||||
deviceID: identity.hostID, label: "Mac C", staticPublicKey: identity.staticPublicKey,
|
||||
scope: .control, pairedAt: Date(timeIntervalSince1970: 1_000),
|
||||
lastSeenAt: Date(timeIntervalSince1970: 2_000), kind: .mac,
|
||||
capabilities: .hostAndAgents,
|
||||
addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 2_000))))
|
||||
let member = MeshMember(
|
||||
deviceID: identity.hostID, label: "Mac C", kind: .mac, capabilities: .hostAndAgents,
|
||||
staticPublicKey: identity.staticPublicKey,
|
||||
addresses: PeerAddresses(lanHint: "c.local:6000", updatedAt: Date(timeIntervalSince1970: 9_000)),
|
||||
pairedAt: Date(timeIntervalSince1970: 1_000),
|
||||
lastSeenAt: Date(timeIntervalSince1970: 9_000))
|
||||
let changed = await MeshRosterMerge.apply(
|
||||
MeshRosterPush(members: [member]), into: store, selfDeviceID: selfID)
|
||||
#expect(changed == false)
|
||||
// …but the freshness metadata still landed.
|
||||
#expect(await store.device(identity.hostID)?.lastSeenAt == Date(timeIntervalSince1970: 9_000))
|
||||
}
|
||||
|
||||
/// Re-applying the same roster reports no change — the idempotence the gossip loop relies on to
|
||||
/// converge instead of ping-ponging.
|
||||
@Test func reMergeIsIdempotent() async throws {
|
||||
|
||||
@@ -132,6 +132,64 @@ import NucleicProtocol
|
||||
#expect(socket.closed || true) // stop after a drop is a no-op on the dead socket
|
||||
}
|
||||
|
||||
/// Generations of one device share a routing tag, so a *stale* channel's late close (a
|
||||
/// superseded handler's dedup-grace teardown, the liveness reaper) must not evict the
|
||||
/// successor channel occupying the slot — that starved the device's live connection and
|
||||
/// spawned a duplicate handler on its next frame.
|
||||
@Test func staleChannelCloseDoesNotEvictSuccessor() async throws {
|
||||
let socket = FakeRoomSocket()
|
||||
let listener = RelayListener(dial: { socket })
|
||||
try await listener.connect()
|
||||
let accepted = try listener.start()
|
||||
|
||||
// Collect every accept on one long-lived consumer (an AsyncStream terminates when a
|
||||
// partial iterator is dropped, so per-accept `firstChannel` calls can't see a second one).
|
||||
let box = AcceptBox()
|
||||
let consumer = Task { for await c in accepted { box.add(c) } }
|
||||
defer { consumer.cancel() }
|
||||
|
||||
func waitForAccepts(_ n: Int) async -> Bool {
|
||||
for _ in 0..<200 {
|
||||
if box.count >= n { return true }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
socket.push(.binary(framed(Data([1]), from: "phone-A")))
|
||||
#expect(await waitForAccepts(1))
|
||||
let first = box.get(0)
|
||||
|
||||
// The device drops (presence without it) → its slot is reaped…
|
||||
socket.push(.text(#"{"t":"presence","peers":[{"deviceId":"host","role":"host"}]}"#))
|
||||
// …and reconnects: the next frame opens a fresh channel for the same tag.
|
||||
socket.push(.binary(framed(Data([2]), from: "phone-A")))
|
||||
#expect(await waitForAccepts(2))
|
||||
let second = box.get(1)
|
||||
|
||||
// The stale generation's handler winds down late — this must NOT evict the successor.
|
||||
first?.close()
|
||||
socket.push(.binary(framed(Data([3]), from: "phone-A")))
|
||||
|
||||
var frames = second!.frames().makeAsyncIterator()
|
||||
#expect(await frames.next() == Data([2]))
|
||||
#expect(await frames.next() == Data([3])) // still routed to the live channel
|
||||
#expect(box.count == 2) // no duplicate third channel was spawned
|
||||
|
||||
listener.stop()
|
||||
}
|
||||
|
||||
final class AcceptBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var channels: [any FrameChannel] = []
|
||||
func add(_ c: any FrameChannel) { lock.lock(); channels.append(c); lock.unlock() }
|
||||
func get(_ i: Int) -> (any FrameChannel)? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return channels.count > i ? channels[i] : nil
|
||||
}
|
||||
var count: Int { lock.lock(); defer { lock.unlock() }; return channels.count }
|
||||
}
|
||||
|
||||
@Test func malformedEnvelopeIsDroppedNotFatal() async throws {
|
||||
let socket = FakeRoomSocket()
|
||||
let listener = RelayListener(dial: { socket })
|
||||
|
||||
@@ -102,6 +102,11 @@ async function handleRelay(request: Request, env: Env, url: URL): Promise<Respon
|
||||
const v = await verifyToken(env.RELAY_TOKEN_SECRET, token);
|
||||
if (!v.ok) return new Response(`token ${v.error}`, { status: 401 });
|
||||
if (v.claims.typ !== "c") return new Response("connection token required", { status: 401 });
|
||||
// Defense in depth for the reserved deviceId (also rejected at mint time — see
|
||||
// mintMembership): a client tagged "host" would collide with the room's role tag.
|
||||
if (v.claims.deviceId === "host" && v.claims.role !== "host") {
|
||||
return new Response("reserved deviceId", { status: 403 });
|
||||
}
|
||||
if (await env.NUCLEIC_RELAY_TOKENS.get(`revoked:${v.claims.deviceId}`)) {
|
||||
return new Response("revoked", { status: 403 });
|
||||
}
|
||||
@@ -144,6 +149,11 @@ async function mintMembership(request: Request, env: Env): Promise<Response> {
|
||||
|
||||
const body = (await safeJson(request)) as { roomId?: string; deviceId?: string; role?: string } | null;
|
||||
if (!body?.deviceId) return new Response("deviceId required", { status: 422 });
|
||||
// "host" is the room's role tag in the DO's hibernation-tag namespace — a *client* named
|
||||
// "host" would be matched by `getWebSockets("host")` and blackhole the room's routing.
|
||||
if (body.deviceId === "host" && body.role !== "host") {
|
||||
return new Response("reserved deviceId", { status: 422 });
|
||||
}
|
||||
|
||||
let roomId: string;
|
||||
if (adminOk(request, env)) {
|
||||
|
||||
@@ -167,6 +167,15 @@ export async function authedHostRoom(kv: KVNamespace, bearer: string | null): Pr
|
||||
const gotHash = await sha256Hex(enc.encode(secret));
|
||||
// Both operands are fixed-length SHA-256 hex, so a char-wise compare is constant-time.
|
||||
if (!constantTimeEqualStr(wantHash, gotHash)) return null;
|
||||
// Slide the credential's expiry on every successful use, as HOST_ROOM_TTL documents — a
|
||||
// continuously-active host must never silently lapse into a forced re-enrollment at the
|
||||
// one-year mark. Best-effort: a failed re-put just leaves the old TTL running.
|
||||
try {
|
||||
await kv.put(`hostauth:${hostId}`, wantHash, { expirationTtl: HOST_ROOM_TTL });
|
||||
await kv.put(`hostroom:${hostId}`, roomId, { expirationTtl: HOST_ROOM_TTL });
|
||||
} catch {
|
||||
/* keep the current TTL */
|
||||
}
|
||||
return { hostId, roomId };
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,20 @@ import { apnsConfig, approvalPayload, clearPayload, sendApns, sendApnsClear } fr
|
||||
interface SocketMeta {
|
||||
role: "host" | "client";
|
||||
deviceId: string;
|
||||
/** Hex of the 8-byte routing tag, precomputed at accept. Routing used to await a WebCrypto
|
||||
* digest per frame (per *client* per frame on the host→client leg) — pure overhead on the
|
||||
* DO's single thread, and awaiting a non-storage promise inside `webSocketMessage` releases
|
||||
* the input gate, letting a later frame's handler interleave with (and potentially reorder
|
||||
* around) an earlier one — fatal to the Noise nonce sequence. Optional: sockets accepted
|
||||
* before this field shipped hibernate with attachments that lack it; routing lazily
|
||||
* backfills those. */
|
||||
tagHex?: string;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
let s = "";
|
||||
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Routing-envelope version byte + a hard ceiling well above the app's 16 MiB frame cap but
|
||||
@@ -46,12 +60,6 @@ async function deviceTag(deviceId: string): Promise<Uint8Array> {
|
||||
return new Uint8Array(digest).slice(0, 8);
|
||||
}
|
||||
|
||||
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export class Room implements DurableObject {
|
||||
// Plain field assignment (not constructor parameter-property shorthand) so the class can be
|
||||
// imported under `node --experimental-strip-types`, which the test runner uses.
|
||||
@@ -71,6 +79,14 @@ export class Room implements DurableObject {
|
||||
}
|
||||
const role = req.headers.get("x-nucleic-role") === "host" ? "host" : "client";
|
||||
const deviceId = req.headers.get("x-nucleic-device") ?? "unknown";
|
||||
// The hibernation tags below are [role, deviceId] in one namespace, so a *client* claiming
|
||||
// the literal deviceId "host" would be matched by `getWebSockets("host")` — routing every
|
||||
// client→host frame into that client (a blackhole; E2EE keeps content safe, but the room
|
||||
// is dead) and getting closed as "superseded" by real host reconnects. The deviceId is
|
||||
// client-influenced (it originates in the device's own hello), so reject the collision.
|
||||
if (role !== "host" && deviceId === "host") {
|
||||
return new Response("reserved deviceId", { status: 403 });
|
||||
}
|
||||
|
||||
// Relay presence → runner wake (docs/CARBON_RUNNER.md §3): a client arriving in a room
|
||||
// with no host may mean the host is an Carbon runner whose container is asleep — nudge
|
||||
@@ -95,8 +111,9 @@ export class Room implements DurableObject {
|
||||
}
|
||||
|
||||
const { 0: client, 1: server } = new WebSocketPair();
|
||||
const tagHex = bytesToHex(await deviceTag(deviceId));
|
||||
this.state.acceptWebSocket(server, [role, deviceId]);
|
||||
server.serializeAttachment({ role, deviceId } satisfies SocketMeta);
|
||||
server.serializeAttachment({ role, deviceId, tagHex } satisfies SocketMeta);
|
||||
|
||||
this.broadcastPresence();
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
@@ -122,19 +139,34 @@ export class Room implements DurableObject {
|
||||
if (me.role === "host") {
|
||||
await this.routeFromHost(message);
|
||||
} else {
|
||||
await this.routeFromClient(me.deviceId, message);
|
||||
await this.routeFromClient(ws, me, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** A socket's routing-tag hex, from the attachment when present (the normal case — computed
|
||||
* once at accept) or lazily backfilled for a socket that hibernated with a pre-`tagHex`
|
||||
* attachment. Keeping the tag in the attachment makes frame routing synchronous: no awaited
|
||||
* digest per frame, and no input-gate release between reading a frame and forwarding it. */
|
||||
private async tagHexFor(ws: WebSocket, meta: SocketMeta): Promise<string> {
|
||||
if (meta.tagHex) return meta.tagHex;
|
||||
const tagHex = bytesToHex(await deviceTag(meta.deviceId));
|
||||
try {
|
||||
ws.serializeAttachment({ ...meta, tagHex } satisfies SocketMeta);
|
||||
} catch {
|
||||
/* socket closing — the computed value still serves this frame */
|
||||
}
|
||||
return tagHex;
|
||||
}
|
||||
|
||||
/** client → host: prepend [ver][tag = sha256(senderDeviceId)[:8]] so the host can demux which
|
||||
* peer sent this frame, then deliver to the single host socket. */
|
||||
private async routeFromClient(senderDeviceId: string, payload: ArrayBuffer): Promise<void> {
|
||||
private async routeFromClient(ws: WebSocket, me: SocketMeta, payload: ArrayBuffer): Promise<void> {
|
||||
const host = this.state.getWebSockets("host")[0];
|
||||
if (!host) return; // no host present; the client will retry after reconnect
|
||||
const tag = await deviceTag(senderDeviceId);
|
||||
const tagHex = await this.tagHexFor(ws, me);
|
||||
const framed = new Uint8Array(ENVELOPE_LEN + payload.byteLength);
|
||||
framed[0] = ENVELOPE_VERSION;
|
||||
framed.set(tag, 1);
|
||||
for (let i = 0; i < 8; i++) framed[1 + i] = parseInt(tagHex.slice(i * 2, i * 2 + 2), 16);
|
||||
framed.set(new Uint8Array(payload), ENVELOPE_LEN);
|
||||
try {
|
||||
host.send(framed);
|
||||
@@ -149,12 +181,12 @@ export class Room implements DurableObject {
|
||||
private async routeFromHost(message: ArrayBuffer): Promise<void> {
|
||||
const bytes = new Uint8Array(message);
|
||||
if (bytes.length < ENVELOPE_LEN || bytes[0] !== ENVELOPE_VERSION) return; // malformed → drop
|
||||
const tag = bytes.subarray(1, ENVELOPE_LEN);
|
||||
const tagHex = bytesToHex(bytes.subarray(1, ENVELOPE_LEN));
|
||||
const raw = bytes.subarray(ENVELOPE_LEN);
|
||||
for (const client of this.state.getWebSockets("client")) {
|
||||
const m = client.deserializeAttachment() as SocketMeta | null;
|
||||
if (!m) continue;
|
||||
if (bytesEqual(await deviceTag(m.deviceId), tag)) {
|
||||
if ((await this.tagHexFor(client, m)) === tagHex) {
|
||||
try {
|
||||
client.send(raw);
|
||||
} catch {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Room } from "../src/room.ts";
|
||||
interface Meta {
|
||||
role: "host" | "client";
|
||||
deviceId: string;
|
||||
/** Precomputed routing-tag hex (optional — old attachments lack it; routing backfills). */
|
||||
tagHex?: string;
|
||||
}
|
||||
|
||||
class FakeWS {
|
||||
@@ -192,6 +194,28 @@ test("host frame with an unknown tag is dropped, not broadcast", async () => {
|
||||
assert.equal(phoneA.sent.length, 0);
|
||||
});
|
||||
|
||||
test("routing backfills tagHex into a pre-tagHex attachment and then routes off it", async () => {
|
||||
const state = new FakeState();
|
||||
const host = ws("host", "host-dev");
|
||||
const phone = ws("client", "phone-A"); // attachment has no tagHex (old hibernated socket)
|
||||
state.add(host);
|
||||
state.add(phone);
|
||||
|
||||
await room(state).webSocketMessage(phone as never, new Uint8Array([1]).buffer);
|
||||
const expected = [...(await deviceTag("phone-A"))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
assert.equal(phone.deserializeAttachment().tagHex, expected); // backfilled once
|
||||
|
||||
// A precomputed (even attacker-supplied-nonsense) tagHex is what routing trusts — pin that
|
||||
// the fast path reads the attachment rather than re-deriving from deviceId.
|
||||
const phoneOdd = new FakeWS({ role: "client", deviceId: "phone-B", tagHex: "00".repeat(8) });
|
||||
state.add(phoneOdd);
|
||||
const framed = new Uint8Array(9 + 1);
|
||||
framed[0] = 0x01; // version + all-zero tag targets phoneOdd's stored tagHex
|
||||
framed.set(new Uint8Array([7]), 9);
|
||||
await room(state).webSocketMessage(host as never, framed.buffer);
|
||||
assert.equal(phoneOdd.sent.length, 1);
|
||||
});
|
||||
|
||||
test("client→host frame is dropped when no host is present", async () => {
|
||||
const state = new FakeState();
|
||||
const phoneA = ws("client", "phone-A");
|
||||
|
||||
@@ -264,7 +264,8 @@ final class HostConnection {
|
||||
// a gossiped address carries only the IP.
|
||||
tailnetPort: hasTailnet ? 43_753 : nil,
|
||||
relayRoomID: member.addresses?.relayRoomID,
|
||||
relayMembershipToken: nil, relayURL: nil)
|
||||
relayMembershipToken: nil, relayURL: nil,
|
||||
addressesUpdatedAt: member.addresses?.updatedAt)
|
||||
}
|
||||
|
||||
/// Whether the dialable endpoints of a gossiped record differ from the one on file — the
|
||||
@@ -588,16 +589,25 @@ final class HostConnection {
|
||||
guard fingerprint != self.hostID else { continue } // that's the Mac we're on
|
||||
let incoming = Self.pairedHost(from: member)
|
||||
let existing = IdentityStore.pairedHost(id: fingerprint)
|
||||
IdentityStore.mergePairedHost(incoming)
|
||||
// Re-dial not only when a Mac is brand-new, but also when a known Mac's dialable
|
||||
// address changed — a Mac's LAN port is OS-assigned (`.any`), so it lands on a
|
||||
// fresh one every relaunch and re-gossips it. `mergePairedHost` updates the
|
||||
// registry in place, but a connection already retrying the dead old address won't
|
||||
// pick that up on its own (its retry loop reuses the address it was last handed),
|
||||
// so without forcing a fresh reconnect here the phone never reconnects to it.
|
||||
if existing == nil || Self.dialableAddressChanged(from: existing!, to: incoming) {
|
||||
//
|
||||
// Compare against the POST-merge record, not the raw gossip: the merge deliberately
|
||||
// preserves fields the gossip omits (a relay room learned from the host's own
|
||||
// `relayMembership` push, stale-vs-fresh address arbitration), so a member gossiped
|
||||
// by a Mac that lacks those fields is NOT a change. Comparing pre-merge made every
|
||||
// such push read as "changed" forever — a mesh-wide reconnect storm that tore down
|
||||
// in-flight handshakes on each gossip wave and pinned connections on "Connecting…".
|
||||
let merged = IdentityStore.pairedHost(id: fingerprint)
|
||||
if existing == nil
|
||||
|| (merged != nil && Self.dialableAddressChanged(from: existing!, to: merged!)) {
|
||||
changed = true
|
||||
}
|
||||
IdentityStore.mergePairedHost(incoming)
|
||||
}
|
||||
for tombstone in push.tombstones {
|
||||
// fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key.
|
||||
@@ -722,6 +732,13 @@ final class HostConnection {
|
||||
if case .failed = connectivity {} else {
|
||||
connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.")
|
||||
}
|
||||
} else if case .failed = connectivity {
|
||||
// keep the surfaced error
|
||||
} else {
|
||||
// Reconnect-mode chain exhausted through channel closes (the LAN/relay connect
|
||||
// watchdogs surface that way) — mirror `asyncAttemptFailed` so the row reads
|
||||
// "host offline" through the retry backoff instead of a stale "Connecting…".
|
||||
connectivity = .hostOffline
|
||||
}
|
||||
connectPlan = nil
|
||||
callbacks.didUpdate()
|
||||
|
||||
@@ -28,6 +28,11 @@ struct PairedHost: Codable, Equatable {
|
||||
var relayRoomID: String?
|
||||
var relayMembershipToken: String?
|
||||
var relayURL: String?
|
||||
/// When the gossiped addresses this record's dial hints came from were stamped at their
|
||||
/// origin (`PeerAddresses.updatedAt`) — the freshness clock `mergePairedHost` arbitrates
|
||||
/// with, so a Mac gossiping a stale snapshot can't clobber a fresher one. Nil for records
|
||||
/// predating the field and for hints seeded straight from a pairing QR.
|
||||
var addressesUpdatedAt: Date? = nil
|
||||
|
||||
var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
|
||||
}
|
||||
@@ -119,9 +124,17 @@ enum IdentityStore {
|
||||
static func mergePairedHost(_ host: PairedHost) {
|
||||
var hosts = pairedHosts()
|
||||
if let index = hosts.firstIndex(where: { $0.fingerprint == host.fingerprint }) {
|
||||
var merged = host
|
||||
let existing = hosts[index]
|
||||
// Freshness gate on the dial hints: two Macs can gossip disagreeing snapshots of a
|
||||
// third's addresses (one still holds its pre-relaunch LAN port). Adopting the incoming
|
||||
// hints blindly made the record — and every dial loop keyed off it — flip-flop between
|
||||
// the stale and fresh snapshots on alternating pushes. The origin's `updatedAt` rides
|
||||
// in as `addressesUpdatedAt`; an undated record loses to any dated one.
|
||||
let incomingAt = host.addressesUpdatedAt ?? .distantPast
|
||||
let existingAt = existing.addressesUpdatedAt ?? .distantPast
|
||||
var merged = incomingAt >= existingAt ? host : existing
|
||||
merged.deviceID = existing.deviceID // keep our established id for this host
|
||||
merged.hostName = host.hostName // labels carry no clock; newest gossip wins
|
||||
merged.relayMembershipToken = host.relayMembershipToken ?? existing.relayMembershipToken
|
||||
merged.relayRoomID = host.relayRoomID ?? existing.relayRoomID
|
||||
merged.relayURL = host.relayURL ?? existing.relayURL
|
||||
|
||||
Reference in New Issue
Block a user