Recommend Tailscale when the remote Orca runtime is unreachable (#6637)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <help@stably.ai> --------- Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
004b4d2991
commit
3f39d7548b
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<TResult>(
|
||||
response: RuntimeRpcResponse<TResult>,
|
||||
endpoint: string
|
||||
): RuntimeRpcResponse<TResult> {
|
||||
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<RuntimeRpcResponse<RuntimeStatus>> {
|
||||
const environment = resolveEnvironment(userDataPath, selector)
|
||||
const pairing = getPreferredPairingOffer(environment)
|
||||
let response: RuntimeRpcResponse<RuntimeStatus>
|
||||
try {
|
||||
response = await sendRemoteRuntimeRequest<RuntimeStatus>(
|
||||
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<RuntimeRpcResponse<unknown>> {
|
||||
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<ArrayBufferLike>) =>
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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}.`
|
||||
}
|
||||
Loading…
Reference in New Issue