Merge nucleic/rustic-maple-marten into dev

This commit is contained in:
2026-07-12 03:00:32 -07:00
parent 4190dd8177
commit 3f9decf658
17 changed files with 412 additions and 87 deletions
+8 -4
View File
@@ -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)
}
}
+46 -6
View File
@@ -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 stopstart 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)
}
+18 -1
View File
@@ -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
}
}
+10 -5
View File
@@ -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
}
+16 -6
View File
@@ -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
+10 -4
View File
@@ -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 })
+10
View File
@@ -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)) {
+9
View File
@@ -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 };
}
+45 -13
View File
@@ -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 {
+24
View File
@@ -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