Tailnet: interactive browser login + LAN↔tailnet fallback

Browser login — the auth key is now optional on both platforms. When the
embedded node starts with no key, TailnetNode asks the backend (LocalAPI
backendStatus — IPN state, deliberately not filesystem heuristics: tsnet
writes logs and a machine key on every start, registered or not) whether a
login is needed, triggers login-interactive, surfaces the auth URL through a
new statusStream()/.needsLogin, and waits for Running (4-minute deadline,
generation-fenced against stop/restart). The Mac auto-opens the login page
from Settings ▸ Remote and shows a re-open button; the iPhone auto-opens only
during user-initiated pairing (a background reconnect that suddenly needs a
login must not eject the user to Safari — Settings ▸ Tailscale carries the
link). The remote-access toggle now reflects the in-flight start instead of
snapping off for the whole login window, and toggling off mid-start is
honored at both commit points (before and after host.start).

LAN↔tailnet fallback — picking Tailnet now keeps LAN on too: the host runs
both listeners under a new CompositeSyncListener (merged accept stream; one
child ending doesn't end the rest) and the pairing QR carries both hints.
The phone builds an ordered candidate chain — LAN first (QR hint or Bonjour),
tailnet second — and walks it on pair and reconnect, so a phone that leaves
the Mac's Wi‑Fi rolls over to the tailnet and rolls back when it returns.
A per-channel 4s connect guard (readiness-checking, bound to exactly its
channel) keeps a stale LAN hint from hanging the chain; a stale-client guard
in consume() keeps a replaced client's tail events from advancing it; chain
exhaustion during pairing lands in a terminal failure instead of spinning on
"Connecting…"; routine pre-fallback handshake errors no longer flash the red
error bubble. "Connected · LAN / Tailnet" shows whichever transport won.

Multi-agent review: 11 confirmed findings (incl. the login gate being dead
code via tsnet's eager state-dir writes, and two connect-timeout races), all
fixed and re-verified. Suite green (24 sync-related tests incl. 3 new
CompositeSyncListener tests); macOS + iOS builds clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-03 16:14:45 -07:00
co-authored by Claude Fable 5
parent 94a962bd20
commit 272039cca5
3 changed files with 224 additions and 76 deletions
@@ -155,6 +155,9 @@ final class RemoteStore: ObservableObject {
private var activeTransport: SyncTransportHint = .lan
/// The phone's embedded Tailscale node state, for Settings (nil = not running).
@Published private(set) var tailnetStatus: String?
/// The Tailscale interactive-login URL while the node waits for a browser login (a
/// first tailnet start with no auth key). Auto-opened; Settings shows a re-open button.
@Published private(set) var tailnetLoginURL: URL?
private var seenSeq: Set<UInt64> = []
private var reconnectAttempts = 0
@@ -290,99 +293,168 @@ final class RemoteStore: ObservableObject {
""")
}
/// Pair from a scanned QR (SYNC §4.2): connect over the transport the QR names — LAN
/// (explicit hint first, else Bonjour) or the Mac's tailnet IP via the phone's embedded
/// Tailscale node — run XXpsk0, and on success pin the host key for future IK reconnects.
/// One dialable way to reach the Mac. A connect builds an ordered candidate list — LAN
/// first (cheapest when reachable), then the tailnet (SYNC §3.2's LAN-then-fallback
/// ordering) — and `attempt` walks it until one carries a session.
private enum TransportAttempt {
case lan(NWEndpoint)
case tailnet(host: String, port: UInt16)
}
/// The in-progress connect: remaining candidates plus everything needed to start a
/// client on whichever one succeeds. Cleared on `.ready` (chain done) and by teardown.
private struct ConnectPlan {
var remaining: [TransportAttempt]
let hostStaticKey: Data
let mode: SyncClient.Mode
let deviceID: String
let pairingPayload: PairingPayload?
}
private var connectPlan: ConnectPlan?
/// Kills a LAN attempt whose TCP connect just hangs (stale IP hint) so the chain can
/// move on — NWConnection's own timeout is far too slow for a fallback decision.
private var lanConnectTimeout: Task<Void, Never>?
/// Pair from a scanned QR (SYNC §4.2): try the QR's transports in order (LAN hint or
/// Bonjour first, then the Mac's tailnet IP via the phone's embedded node), run XXpsk0,
/// and on success pin the host key for future IK reconnects.
func pair(with payload: PairingPayload) {
teardown()
connectivity = .connecting
hostName = payload.hostName
let deviceID = IdentityStore.deviceID()
switch payload.transportHint {
case .lan:
activeTransport = .lan
guard let endpoint = resolveEndpoint(
fingerprint: payload.hostStaticKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort)
else { connectivity = .failed("No Mac found on this network"); return }
startClient(
channel: makeChannel(endpoint), hostStaticKey: payload.hostStaticKey,
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
pairingPayload: payload)
case .tailnet:
activeTransport = .tailnet
guard let tailnetHost = payload.tailnetHost, let tailnetPort = payload.tailnetPort else {
connectivity = .failed("The pairing code is missing the Mac's tailnet address.")
return
}
connectTask = Task { [weak self] in
guard let self else { return }
do {
let channel = try await self.tailnetChannel(host: tailnetHost, port: tailnetPort)
guard !Task.isCancelled else { channel.close(); return }
self.startClient(
channel: channel, hostStaticKey: payload.hostStaticKey,
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
pairingPayload: payload)
} catch {
guard !Task.isCancelled else { return }
self.connectivity = .failed(error.localizedDescription)
}
}
case .relay:
connectivity = .failed("Relay connections aren't supported yet.")
case nil:
guard let hint = payload.transportHint else {
connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.")
return
}
guard hint != .relay else {
connectivity = .failed("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)
guard !candidates.isEmpty else {
connectivity = .failed(hint == .tailnet && !TailnetSupport.isBuiltIn
? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in"
: "No Mac found on this network")
return
}
connectPlan = ConnectPlan(
remaining: candidates, hostStaticKey: payload.hostStaticKey,
mode: .pair(secret: payload.pairingSecret), deviceID: IdentityStore.deviceID(),
pairingPayload: payload)
_ = tryNextCandidate()
}
/// Reconnect to the already-paired host using IK against the pinned static key, over
/// whichever transport the pairing recorded.
/// Reconnect to the already-paired host using IK against the pinned static key: LAN
/// when reachable, else the pairing's tailnet hint — so a phone that leaves the Mac's
/// Wi‑Fi rolls over to the tailnet and rolls back when it returns.
func reconnect() {
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
teardown()
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
hostName = host.hostName
switch host.transportHint {
case .lan:
guard host.transportHint != .relay else {
connectivity = .failed("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)
guard !candidates.isEmpty else {
connectivity = .hostOffline
scheduleRetry()
return
}
connectPlan = ConnectPlan(
remaining: candidates, hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
_ = tryNextCandidate()
}
/// LAN first (explicit hint, else a Bonjour match), tailnet second when the pairing
/// carries one and this build can dial it.
private func buildCandidates(
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
tailnet: (host: String?, port: UInt16?)?
) -> [TransportAttempt] {
var candidates: [TransportAttempt] = []
if let endpoint = resolveEndpoint(fingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
candidates.append(.lan(endpoint))
}
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
candidates.append(.tailnet(host: host, port: port))
}
return candidates
}
/// Pop and dial the next candidate. False when the plan is exhausted (or gone) — the
/// caller then applies its terminal failure handling.
private func tryNextCandidate() -> Bool {
guard var plan = connectPlan, !plan.remaining.isEmpty else { return false }
let next = plan.remaining.removeFirst()
connectPlan = plan
attempt(next, plan: plan)
return true
}
private func attempt(_ candidate: TransportAttempt, plan: ConnectPlan) {
teardownClient()
switch candidate {
case .lan(let endpoint):
activeTransport = .lan
guard let endpoint = resolveEndpoint(
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
else { connectivity = .hostOffline; scheduleRetry(); return }
startClient(
channel: makeChannel(endpoint), hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
case .tailnet:
activeTransport = .tailnet
guard let tailnetHost = host.tailnetHost, let tailnetPort = host.tailnetPort else {
connectivity = .failed("Missing tailnet address — pair with your Mac again.")
return
let channel = makeChannel(endpoint)
// Close the channel if TCP isn't up within the window (a stale IP hint would
// otherwise hang the chain on NWConnection's slow timeout); the finished stream
// then advances to the next candidate. The timer checks readiness itself — it's
// bound to exactly this channel, so a stale timer can never hit a later attempt.
lanConnectTimeout?.cancel()
lanConnectTimeout = Task { [weak channel] in
try? await Task.sleep(for: .seconds(4))
guard !Task.isCancelled, let channel, !channel.isReady else { return }
channel.close()
}
startClient(
channel: channel, hostStaticKey: plan.hostStaticKey,
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
case .tailnet(let host, let port):
activeTransport = .tailnet
connectTask = Task { [weak self] in
guard let self else { return }
do {
let channel = try await self.tailnetChannel(host: tailnetHost, port: tailnetPort)
let channel = try await self.tailnetChannel(host: host, port: port)
guard !Task.isCancelled else { channel.close(); return }
self.startClient(
channel: channel, hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
channel: channel, hostStaticKey: plan.hostStaticKey,
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
} catch {
guard !Task.isCancelled else { return }
switch error {
case TailnetError.notBuiltIn, TailnetError.notConfigured:
// Retrying can't fix a missing auth key or a build without Tailscale.
self.connectivity = .failed(error.localizedDescription)
default:
self.connectivity = .hostOffline
self.scheduleRetry()
}
self.tailnetAttemptFailed(error, isPairing: plan.pairingPayload != nil)
}
}
case .relay:
connectivity = .failed("Relay connections aren't supported yet.")
}
}
/// The tailnet is always the last candidate, so its failure ends the chain: terminal
/// for pairing and for anything retrying can't fix; otherwise offline + backoff.
private func tailnetAttemptFailed(_ error: Error, isPairing: Bool) {
if tryNextCandidate() { return }
if isPairing {
connectivity = .failed(error.localizedDescription)
return
}
switch error {
case TailnetError.notBuiltIn, TailnetError.notConfigured:
connectivity = .failed(error.localizedDescription)
default:
connectivity = .hostOffline
scheduleRetry()
}
}
/// Create the `SyncClient` on an established channel and start consuming its events —
/// the tail of every connect path, LAN or tailnet, pair or reconnect.
private func startClient(
@@ -404,14 +476,38 @@ final class RemoteStore: ObservableObject {
private func tailnetChannel(host: String, port: UInt16) async throws -> FDFrameChannel {
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
let config = Self.phoneTailnetConfig()
if config.authKey == nil, !config.hasExistingState {
throw TailnetError.notConfigured("Add your Tailscale auth key in Settings ▸ Tailscale first.")
}
tailnetStatus = "Starting…"
// Mirror node status while the start is in flight. A first start with no auth key
// goes through the interactive browser login; pairing usually runs from the scanner
// sheet — not Settings — so take the user straight to the approval page.
let watcher = Task { [weak self] in
for await status in await TailnetNode.shared.statusStream() {
guard let self, !Task.isCancelled else { break }
self.tailnetStatus = status.label
if case .needsLogin(let url) = status, let loginURL = URL(string: url) {
if self.tailnetLoginURL != loginURL {
self.tailnetLoginURL = loginURL
// Eject to Safari only for user-initiated pairing — the user is
// actively watching. A routine reconnect that suddenly needs a
// login (node revoked, state wiped) must not yank them out of the
// app; Settings ▸ Tailscale carries the login link instead.
if self.connectPlan?.pairingPayload != nil {
UIApplication.shared.open(loginURL, options: [:], completionHandler: nil)
}
}
} else {
self.tailnetLoginURL = nil
}
}
}
defer {
watcher.cancel()
tailnetLoginURL = nil
}
do {
try await TailnetNode.shared.ensureRunning(config: config)
} catch TailnetError.timedOut(let message) {
// A login timeout won't fix itself — retrying would just block 45s per lap.
// A login/auth timeout won't fix itself — retrying would just block per lap.
// Rethrow as .notConfigured so reconnect() treats it as terminal, not offline.
tailnetStatus = await TailnetNode.shared.status.label
throw TailnetError.notConfigured(message)
@@ -825,7 +921,11 @@ final class RemoteStore: ObservableObject {
eventTask = Task { [weak self] in
let stream = await client.start()
for await event in stream {
await self?.handle(event, pairingPayload: pairingPayload)
// A replaced client's tail events (`.failed` is always chased by `.closed`)
// must not leak into the new attempt — they'd advance the candidate chain
// or schedule retries against a connection that no longer exists.
guard let self, self.client === client else { break }
await self.handle(event, pairingPayload: pairingPayload)
}
}
}
@@ -836,6 +936,7 @@ final class RemoteStore: ObservableObject {
break
case .ready(let welcome):
reconnectAttempts = 0
connectPlan = nil // the chain found its transport
connectivity = .connected(activeTransport)
hostName = welcome.host.hostName
capabilities = welcome.capabilities
@@ -917,12 +1018,28 @@ final class RemoteStore: ObservableObject {
// Losing an approval race isn't an error worth interrupting for; the card
// collapses on the matching `approvalResolved`.
guard error.code != .alreadyResolved else { break }
// While the connect chain is still resolving a transport, a pre-ready error
// (e.g. "handshake closed" from a LAN probe about to fall back to tailnet) is
// routine, not news — the follow-on `.closed` advances the chain silently.
guard connectPlan == nil else { break }
showError(error.message, sessionID: error.sessionID)
case .failed(let message):
// Another candidate may still carry the session (e.g. LAN died → tailnet).
if tryNextCandidate() { break }
connectPlan = nil
connectivity = .failed(message)
scheduleRetry()
case .closed:
if connectivity.isLive { connectivity = .reconnecting }
if !connectivity.isLive, tryNextCandidate() { break }
if connectivity.isLive {
connectivity = .reconnecting
} else if connectPlan?.pairingPayload != nil {
// A pairing chain died silently (every candidate closed pre-welcome).
// There's no retry loop before a pairing succeeds, so without a terminal
// state this would sit on "Connecting…" forever.
connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.")
}
connectPlan = nil
scheduleRetry()
}
}
@@ -963,6 +1080,15 @@ final class RemoteStore: ObservableObject {
retryTask = nil
connectTask?.cancel()
connectTask = nil
connectPlan = nil
teardownClient()
}
/// Drop just the current client/channel — what moving to the next transport candidate
/// needs, without discarding the rest of the plan.
private func teardownClient() {
lanConnectTimeout?.cancel()
lanConnectTimeout = nil
eventTask?.cancel()
eventTask = nil
if let client { Task { await client.disconnect() } }