From 3f39d7548b6b2b701ea61bc9de587c774859582f Mon Sep 17 00:00:00 2001 From: s546126 Date: Mon, 29 Jun 2026 13:41:13 -0700 Subject: [PATCH] Recommend Tailscale when the remote Orca runtime is unreachable (#6637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Recommend Tailscale when the remote Orca runtime is unreachable When a remote-runtime connection fails (RemoteRuntimeClientError "Could not connect to the remote Orca runtime."), append an actionable Tailscale hint to the user-facing error, branched on whether the endpoint is already on a tailnet: - Non-Tailscale endpoint: recommend connecting both devices over Tailscale and pairing with its Tailscale address, with a download link. - Tailscale endpoint (*.ts.net or 100.64.0.0/10): point at the real causes — server offline on the tailnet, or Funnel reverted to tailnet-only — and note that already-paired devices reconnect without re-pairing. Applied at the desktop transport chokepoint (status probe, in-use calls, and subscriptions — connection failures reject, so the hint is applied to the thrown error, not just ok:false responses) and at the web client's connect/timeout sites. New pure shared helper mirrors withMacTailscaleDnsHint. Co-Authored-By: Claude Opus 4.8 * Address review: scope CGNAT hint to IPv4 literals, track re-paired endpoint - isTailscaleEndpoint: gate the 100.64.0.0/10 check on a full IPv4 literal so DNS names like 100.64.0.1.example.com no longer get tailnet-specific advice. - callRuntimeEnvironment: capture the endpoint the queued closure actually used, so a re-pair between enqueue and dispatch can't append the wrong hint. Co-Authored-By: Claude Opus 4.8 * Recognize Tailscale IPv6 endpoints and trailing-dot FQDNs in hint The remote-runtime Tailscale hint classified IPv6 Tailscale nodes (fd7a:115c:a1e0::/48) and trailing-dot FQDNs as non-Tailscale, so a user already reaching their server over Tailscale by IPv6 literal was wrongly told to 'connect both devices to Tailscale'. Pairing endpoints can carry bracketed IPv6 literals (resolvePairingEndpoint), so this is a reachable path. Normalize the extracted host (strip brackets and the trailing FQDN dot) and add an IPv6 ULA-range check. Co-authored-by: Orca --------- Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 Co-authored-by: Jinwoo-H Co-authored-by: Orca --- ...t-transport-routing-tailscale-hint.test.ts | 82 +++++++++ .../runtime-environment-transport-routing.ts | 167 ++++++++++++------ src/renderer/src/web/web-runtime-client.ts | 19 +- .../remote-runtime-tailscale-hint.test.ts | 88 +++++++++ src/shared/remote-runtime-tailscale-hint.ts | 80 +++++++++ 5 files changed, 377 insertions(+), 59 deletions(-) create mode 100644 src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts create mode 100644 src/shared/remote-runtime-tailscale-hint.test.ts create mode 100644 src/shared/remote-runtime-tailscale-hint.ts diff --git a/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts b/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts new file mode 100644 index 000000000..0c8bd1ef7 --- /dev/null +++ b/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto' +import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' +import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { + callRuntimeEnvironment, + getRuntimeEnvironmentStatus, + subscribeRuntimeEnvironment +} from './runtime-environment-transport-routing' + +// Why: prove the wiring, not just the helper — an unreachable endpoint exercises +// the real WebSocket failure → reject → Tailscale-hint join points the settings +// probe (returned ok:false) and in-use calls (thrown) actually use. + +let userDataPath: string + +function seedEnvironment(name: string, endpoint: string): string { + // A valid Curve25519 public key lets the client reach the socket-connect step + // (and fail there) instead of bailing out early on key parsing. + const keyPair = generateKeyPair() + const offer: PairingOffer = { + v: 2, + endpoint, + deviceToken: 'a'.repeat(48), + publicKeyB64: publicKeyToBase64(keyPair.publicKey) + } + const environment = addEnvironmentFromPairingCode(userDataPath, { + name, + pairingCode: encodePairingOffer(offer) + }) + return environment.id +} + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-tailscale-hint-')) +}) + +afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) +}) + +describe('Tailscale hint on remote runtime connection failure', () => { + it('recommends Tailscale on the settings status probe for a non-tailnet endpoint', async () => { + const id = seedEnvironment('lan-host', 'ws://127.0.0.1:9') + const response = await getRuntimeEnvironmentStatus(userDataPath, id, 1000) + expect(response.ok).toBe(false) + if (response.ok === false) { + expect(response.error.message).toContain('connect both devices to Tailscale') + expect(response.error.message).toContain('https://tailscale.com/download') + } + }) + + it('gives tailnet-specific guidance on the status probe for a Tailscale endpoint', async () => { + const id = seedEnvironment('ts-host', 'ws://100.64.0.1:9') + const response = await getRuntimeEnvironmentStatus(userDataPath, id, 800) + expect(response.ok).toBe(false) + if (response.ok === false) { + expect(response.error.message).toContain('Funnel reverted to tailnet-only') + expect(response.error.message).not.toContain('https://tailscale.com/download') + } + }) + + it('augments the thrown error for in-use calls (the toast path)', async () => { + const id = seedEnvironment('lan-host', 'ws://127.0.0.1:9') + await expect(callRuntimeEnvironment(userDataPath, id, 'files.read', {}, 1000)).rejects.toThrow( + /connect both devices to Tailscale/ + ) + }) + + it('augments a subscription that fails to connect initially', async () => { + const id = seedEnvironment('lan-host', 'ws://127.0.0.1:9') + await expect( + subscribeRuntimeEnvironment(userDataPath, id, 'files.watch', {}, 1000, { + onEvent: () => {}, + onClose: () => {} + }) + ).rejects.toThrow(/connect both devices to Tailscale/) + }) +}) diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index a7f76c4c0..0b4b268a2 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -11,6 +11,7 @@ import { subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' import { sendRemoteRuntimeConnectionRequest, @@ -30,16 +31,35 @@ export function clearSharedControlSupport(environmentId: string): void { sharedControlSupport.delete(environmentId) } +// Why: when a remote host is unreachable, point the user at Tailscale as the +// connectivity remedy; the helper no-ops on non-connectivity errors. +function withTailscaleHintForResponse( + response: RuntimeRpcResponse, + endpoint: string +): RuntimeRpcResponse { + if (response.ok === true) { + return response + } + return { + ...response, + error: { + ...response.error, + message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint) + } + } +} + export async function getRuntimeEnvironmentStatus( userDataPath: string, selector: string, timeoutMs?: number ): Promise> { const environment = resolveEnvironment(userDataPath, selector) + const pairing = getPreferredPairingOffer(environment) let response: RuntimeRpcResponse try { response = await sendRemoteRuntimeRequest( - getPreferredPairingOffer(environment), + pairing, 'status.get', undefined, timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS @@ -48,22 +68,28 @@ export async function getRuntimeEnvironmentStatus( // Why: the status UI needs shared-control diagnostics most when the // fresh status probe failed and the host is reconnecting/offline. return attachRemoteControlDiagnostics( - { - id: 'status.get', - ok: false, - error: { - code: 'runtime_unavailable', - message: error instanceof Error ? error.message : String(error) + withTailscaleHintForResponse( + { + id: 'status.get', + ok: false, + error: { + code: 'runtime_unavailable', + message: error instanceof Error ? error.message : String(error) + }, + _meta: { runtimeId: environment.runtimeId } }, - _meta: { runtimeId: environment.runtimeId } - }, + pairing.endpoint + ), environment.id ) } if (response.ok === true) { markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) } - return attachRemoteControlDiagnostics(response, environment.id) + return attachRemoteControlDiagnostics( + withTailscaleHintForResponse(response, pairing.endpoint), + environment.id + ) } export async function callRuntimeEnvironment( @@ -74,41 +100,55 @@ export async function callRuntimeEnvironment( timeoutMs?: number ): Promise> { const environment = resolveEnvironment(userDataPath, selector) - return enqueueRuntimeCall(environment.id, method, async () => { - const currentEnvironment = resolveEnvironment(userDataPath, environment.id) - const pairing = getPreferredPairingOffer(currentEnvironment) - const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS - if (shouldUseCachedRequestConnection(method)) { - const response = await sendRemoteRuntimeConnectionRequest( - currentEnvironment.id, - pairing, - method, - params, - effectiveTimeoutMs - ) + // Why: connection failures reject (they don't resolve as ok:false), so the + // Tailscale hint is applied to the thrown error here — wrapping the resolved + // value would miss the in-use connect/timeout case the toast surfaces. + // Track the endpoint the queued closure actually used: it re-resolves the + // environment, so a re-pair between enqueue and dispatch can change it. + let endpoint = getPreferredPairingOffer(environment).endpoint + try { + return await enqueueRuntimeCall(environment.id, method, async () => { + const currentEnvironment = resolveEnvironment(userDataPath, environment.id) + const pairing = getPreferredPairingOffer(currentEnvironment) + endpoint = pairing.endpoint + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + if (shouldUseCachedRequestConnection(method)) { + const response = await sendRemoteRuntimeConnectionRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) + markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) + return response + } + if ( + method !== 'status.get' && + (await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs)) + ) { + const response = await sendRemoteRuntimeSharedControlRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) + markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) + return response + } + // Why: startup/control-plane RPCs use the proven one-shot path so repo + // hydration cannot be coupled to a stale terminal-control connection. + const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response + }) + } catch (error) { + if (error instanceof Error) { + error.message = withRemoteRuntimeTailscaleHint(error.message, endpoint) } - if ( - method !== 'status.get' && - (await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs)) - ) { - const response = await sendRemoteRuntimeSharedControlRequest( - currentEnvironment.id, - pairing, - method, - params, - effectiveTimeoutMs - ) - markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) - return response - } - // Why: startup/control-plane RPCs use the proven one-shot path so repo - // hydration cannot be coupled to a stale terminal-control connection. - const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) - markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) - return response - }) + throw error + } } export async function subscribeRuntimeEnvironment( @@ -149,33 +189,46 @@ export async function subscribeRuntimeEnvironment( onBinary: (bytes: Uint8Array) => callbacks.onEvent({ type: 'binary' as const, bytes }), onError: (error: { code: string; message: string }) => - callbacks.onEvent({ type: 'error' as const, code: error.code, message: error.message }), + callbacks.onEvent({ + type: 'error' as const, + code: error.code, + message: withRemoteRuntimeTailscaleHint(error.message, pairing.endpoint) + }), onClose: () => { callbacks.onEvent({ type: 'close' as const }) callbacks.onClose() } } - if ( - shouldUseSharedControlSubscription(method) && - !shouldKeepDedicatedSubscriptionSocket(method) && - (await supportsSharedControl(userDataPath, environment, pairing, effectiveTimeoutMs)) - ) { - return await subscribeRemoteRuntimeSharedControlRequest( - environment.id, + // Why: an initial-connect failure rejects (mid-stream drops go through + // onError above), so the hint is applied to the thrown error here too. + try { + if ( + shouldUseSharedControlSubscription(method) && + !shouldKeepDedicatedSubscriptionSocket(method) && + (await supportsSharedControl(userDataPath, environment, pairing, effectiveTimeoutMs)) + ) { + return await subscribeRemoteRuntimeSharedControlRequest( + environment.id, + pairing, + method, + params, + effectiveTimeoutMs, + callbacksWithMarkUsed + ) + } + return await subscribeRemoteRuntimeRequest( pairing, method, params, effectiveTimeoutMs, callbacksWithMarkUsed ) + } catch (error) { + if (error instanceof Error) { + error.message = withRemoteRuntimeTailscaleHint(error.message, pairing.endpoint) + } + throw error } - return await subscribeRemoteRuntimeRequest( - pairing, - method, - params, - effectiveTimeoutMs, - callbacksWithMarkUsed - ) } function markEnvironmentUsedFromResponse( diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 6091c952a..f94cf0447 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -4,6 +4,7 @@ import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' import type { WebPairingOffer } from './web-pairing' +import { withRemoteRuntimeTailscaleHint } from '../../../shared/remote-runtime-tailscale-hint' import { decrypt, decryptBytes, @@ -407,7 +408,14 @@ export class WebRuntimeClient { ws.onclose = () => this.handleSocketClosed(ws) ws.onerror = () => { if (this.state === 'connecting') { - this.rejectAllWaiters(new Error('Could not connect to the remote Orca runtime.')) + this.rejectAllWaiters( + new Error( + withRemoteRuntimeTailscaleHint( + 'Could not connect to the remote Orca runtime.', + this.pairing.endpoint + ) + ) + ) } } } @@ -555,7 +563,14 @@ export class WebRuntimeClient { if (index !== -1) { this.waiters.splice(index, 1) } - reject(new Error('Timed out while connecting to the remote Orca runtime.')) + reject( + new Error( + withRemoteRuntimeTailscaleHint( + 'Timed out while connecting to the remote Orca runtime.', + this.pairing.endpoint + ) + ) + ) }, timeoutMs) this.waiters.push({ resolve: () => { diff --git a/src/shared/remote-runtime-tailscale-hint.test.ts b/src/shared/remote-runtime-tailscale-hint.test.ts new file mode 100644 index 000000000..8bf3e1004 --- /dev/null +++ b/src/shared/remote-runtime-tailscale-hint.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + isTailscaleEndpoint, + withRemoteRuntimeTailscaleHint +} from './remote-runtime-tailscale-hint' + +const UNREACHABLE = 'Could not connect to the remote Orca runtime.' + +describe('isTailscaleEndpoint', () => { + it('matches MagicDNS hostnames', () => { + expect(isTailscaleEndpoint('wss://example-host.tailnet.ts.net')).toBe(true) + expect(isTailscaleEndpoint('ws://host.ts.net:6768')).toBe(true) + }) + + it('matches the 100.64.0.0/10 CGNAT range', () => { + expect(isTailscaleEndpoint('ws://100.64.0.5:6768')).toBe(true) + expect(isTailscaleEndpoint('ws://100.127.255.255:6768')).toBe(true) + }) + + it('matches Tailscale IPv6 (fd7a:115c:a1e0::/48) literals', () => { + // Pairing endpoints can carry a bracketed IPv6 literal (resolvePairingEndpoint). + expect(isTailscaleEndpoint('wss://[fd7a:115c:a1e0::1]:443')).toBe(true) + expect(isTailscaleEndpoint('ws://[fd7a:115c:a1e0:ab12:4843:cd96:626b:1]:6768')).toBe(true) + expect(isTailscaleEndpoint('ws://[2001:db8::1]:6768')).toBe(false) + expect(isTailscaleEndpoint('ws://[::1]:6768')).toBe(false) + }) + + it('rejects non-Tailscale hosts and the surrounding 100.x space', () => { + expect(isTailscaleEndpoint('ws://192.168.1.10:6768')).toBe(false) + expect(isTailscaleEndpoint('wss://orca.example.com')).toBe(false) + expect(isTailscaleEndpoint('ws://100.63.0.1:6768')).toBe(false) + expect(isTailscaleEndpoint('ws://100.128.0.1:6768')).toBe(false) + expect(isTailscaleEndpoint('ws://notts.net.evil.com')).toBe(false) + // A DNS name that merely starts with a CGNAT-shaped label is not a TS IP. + expect(isTailscaleEndpoint('ws://100.64.0.1.example.com:6768')).toBe(false) + }) + + it('handles bare hosts without a scheme and empty input', () => { + expect(isTailscaleEndpoint('host.ts.net')).toBe(true) + // A trailing-dot FQDN is still the same tailnet host. + expect(isTailscaleEndpoint('wss://host.ts.net.')).toBe(true) + expect(isTailscaleEndpoint('')).toBe(false) + expect(isTailscaleEndpoint(null)).toBe(false) + expect(isTailscaleEndpoint(undefined)).toBe(false) + }) +}) + +describe('withRemoteRuntimeTailscaleHint', () => { + it('recommends switching to Tailscale when the endpoint is not on a tailnet', () => { + const result = withRemoteRuntimeTailscaleHint(UNREACHABLE, 'ws://192.168.1.10:6768') + expect(result).toContain(UNREACHABLE) + expect(result).toContain('connect both devices to Tailscale') + expect(result).toContain('https://tailscale.com/download') + }) + + it('points at tailnet-specific causes when the endpoint is already Tailscale', () => { + const result = withRemoteRuntimeTailscaleHint(UNREACHABLE, 'wss://example-host.tailnet.ts.net') + expect(result).toContain('Funnel reverted to tailnet-only') + expect(result).toContain('already-paired devices reconnect with their saved token') + expect(result).not.toContain('https://tailscale.com/download') + }) + + it('covers the close and timeout failure variants', () => { + expect( + withRemoteRuntimeTailscaleHint( + 'Remote Orca runtime closed the connection.', + 'ws://192.168.1.10:6768' + ) + ).toContain('connect both devices to Tailscale') + expect( + withRemoteRuntimeTailscaleHint( + 'Timed out while connecting to the remote Orca runtime.', + 'wss://host.ts.net' + ) + ).toContain('Funnel reverted to tailnet-only') + }) + + it('leaves non-connectivity errors untouched', () => { + const auth = 'Remote Orca runtime rejected the pairing token.' + expect(withRemoteRuntimeTailscaleHint(auth, 'ws://192.168.1.10:6768')).toBe(auth) + }) + + it('is idempotent — does not append the hint twice', () => { + const once = withRemoteRuntimeTailscaleHint(UNREACHABLE, 'ws://192.168.1.10:6768') + const twice = withRemoteRuntimeTailscaleHint(once, 'ws://192.168.1.10:6768') + expect(twice).toBe(once) + }) +}) diff --git a/src/shared/remote-runtime-tailscale-hint.ts b/src/shared/remote-runtime-tailscale-hint.ts new file mode 100644 index 000000000..c04d3da5e --- /dev/null +++ b/src/shared/remote-runtime-tailscale-hint.ts @@ -0,0 +1,80 @@ +/** + * Appends an actionable Tailscale recommendation to remote-runtime connection + * failures, mirroring `withMacTailscaleDnsHint`. Lives in `shared` as a pure, + * dependency-free function so both the main process (desktop transport) and the + * renderer (web client) can route their user-facing errors through it without + * leaking presentation copy into the shared error constructors (which the CLI, + * logs, and mobile typecheck also consume). + */ + +const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' + +// Why: only the "runtime is unreachable" family of failures has a Tailscale +// remedy; auth/protocol errors pass through untouched. +const REMOTE_RUNTIME_UNREACHABLE_RE = + /could not connect to the remote orca runtime|remote orca runtime closed the connection|timed out (?:waiting for|while connecting to) the remote orca runtime/i + +const TAILSCALE_MAGIC_DNS_SUFFIX_RE = /(?:^|\.)ts\.net$/i +// Why: gate the CGNAT check on a full IPv4 literal — the range regex alone also +// matches DNS names like `100.64.0.1.example.com`, which aren't Tailscale IPs. +const IPV4_LITERAL_RE = + /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/ +// Tailscale assigns node IPs from the 100.64.0.0/10 CGNAT range (second octet 64–127). +const TAILSCALE_CGNAT_RE = /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./ +// Tailscale also assigns each node an IPv6 address from the fd7a:115c:a1e0::/48 ULA +// block, and pairing endpoints can carry an IPv6 literal (see resolvePairingEndpoint). +const TAILSCALE_IPV6_RE = /^fd7a:115c:a1e0:/i + +function extractHost(endpoint: string): string | null { + let host: string | null + try { + host = new URL(endpoint).hostname || null + } catch { + // Why: a bare host (no scheme) isn't a valid URL; strip any scheme and take + // the authority up to the first port/path/query delimiter. + host = endpoint.replace(/^[a-z]+:\/\//i, '').split(/[/:?#]/, 1)[0] || null + } + if (!host) { + return null + } + // Why: WHATWG URL keeps IPv6 literals bracketed (`[fd7a:…]`) and FQDNs can carry + // a trailing dot; normalize both so the host checks below see a bare address/name. + return host.replace(/^\[|\]$/g, '').replace(/\.$/, '') || null +} + +export function isTailscaleEndpoint(endpoint: string | null | undefined): boolean { + if (!endpoint) { + return false + } + const host = extractHost(endpoint) + if (!host) { + return false + } + return ( + TAILSCALE_MAGIC_DNS_SUFFIX_RE.test(host) || + (IPV4_LITERAL_RE.test(host) && TAILSCALE_CGNAT_RE.test(host)) || + TAILSCALE_IPV6_RE.test(host) + ) +} + +export function withRemoteRuntimeTailscaleHint( + message: string, + endpoint: string | null | undefined +): string { + if (!REMOTE_RUNTIME_UNREACHABLE_RE.test(message)) { + return message + } + // Why: keep the hint idempotent so a message routed through this helper twice + // (e.g. re-wrapped error response) isn't suffixed with duplicate guidance. + if (/tailscale/i.test(message)) { + return message + } + if (isTailscaleEndpoint(endpoint)) { + // Why: a server already reached over Tailscale fails for tailnet-specific + // reasons, so "use Tailscale" would be useless — point at the real causes. + // Already-paired devices keep their saved token across server restarts, so + // re-pairing only matters when adding a new device. + return `${message} The server may be offline on your tailnet, or its Tailscale Funnel reverted to tailnet-only. Confirm it's reachable; re-pair only when adding a new device, since already-paired devices reconnect with their saved token.` + } + return `${message} If the server is on another network, connect both devices to Tailscale and pair using its Tailscale address (100.x or a *.ts.net name). See ${TAILSCALE_DOWNLOAD_URL}.` +}