diff --git a/docs/reference/2026-07-16-mobile-public-tunnel-pairing-design.md b/docs/reference/2026-07-16-mobile-public-tunnel-pairing-design.md new file mode 100644 index 00000000000..6e3e93d7353 --- /dev/null +++ b/docs/reference/2026-07-16-mobile-public-tunnel-pairing-design.md @@ -0,0 +1,162 @@ +# Mobile public-tunnel pairing + +## Summary + +Orca's runtime and mobile client already accept WebSocket endpoints, including +`wss://` endpoints. The desktop Mobile pairing picker is the remaining gap: it +only accepts an IP address, hostname, or `host:port`, so it cannot advertise a +TLS-terminated public tunnel without falling back to the runtime's `ws://` +scheme. + +This change lets Mobile pairing accept a strict `ws://` or `wss://` endpoint in +addition to the existing address forms. It treats Cloudflare Tunnel and similar +services as user-managed direct connectivity. It does not add a Cloudflare +transport, manage tunnel processes, or carry Cloudflare Access credentials. + +## Current state + +The runtime owns the transport and security contract: + +- `resolvePairingEndpoint` already preserves a complete `ws://` or `wss://` + override. +- Pairing offers already carry the endpoint, a device token, and the runtime's + pinned public key. +- Mobile opens the advertised endpoint and completes Orca's E2EE handshake + before authenticated RPC traffic. +- Orca Relay remains an additive fallback and races the direct endpoint during + pairing when Relay is enabled. + +The renderer has two different input grammars. Runtime sharing accepts a full +WebSocket URL, while Mobile pairing uses `parseManualNetworkAddress`, which is +limited to an IP address, hostname, or `host:port`. Entering +`orca.example.com:443` therefore produces `ws://orca.example.com:443`, not the +`wss://orca.example.com` endpoint required by a public TLS tunnel. + +## Decision + +Add a shared `parseMobilePairingEndpoint` validator and use it in the Mobile +pairing address picker. + +The validator accepts: + +- the existing IPv4, hostname, and `host:port` forms; +- `ws://host[:port]` and `wss://host[:port]`. + +It rejects credentials, paths, query strings, fragments, whitespace, invalid +ports, IPv6, and URL host coercions that the bare-address validator already +rejects. Restricting the first public-tunnel slice to an origin keeps the saved +Mobile endpoint grammar aligned with the Mobile host editor. + +The endpoint remains a string in the existing pairing-offer version. This is an +input-surface change, not a protocol migration. + +## Ownership boundaries + +Orca owns: + +- validating the endpoint it advertises; +- preserving the endpoint in the pairing offer; +- device-token authorization, public-key pinning, E2EE, reconnect, and + diagnostics; +- tests proving a secure public endpoint reaches the Mobile WebSocket client. + +The tunnel operator owns: + +- installing and updating the connector; +- DNS, certificates, tunnel configuration, availability, and billing; +- deciding whether the hostname is public or reachable through a private + Cloudflare One/WARP route. + +This change does not: + +- invoke Cloudflare APIs or persist Cloudflare API tokens; +- install, spawn, or supervise `cloudflared`; +- add Cloudflare-specific endpoint kinds to shared transport types; +- support Cloudflare Access browser cookies or service-token headers; +- change Orca Relay selection, credentials, or E2EE framing. + +Cloudflare Access requires a separate design because the current Mobile client +constructs `new WebSocket(endpoint)` without an authentication cookie or custom +headers. A long-lived Access service token must not be added to the pairing URL +without explicit storage, rotation, revocation, and exposure analysis. + +Publishing the endpoint also makes the WebSocket handshake Internet-reachable. +The existing runtime caps direct WebSocket connections at 128, terminates +connections that do not authenticate within 10 seconds, and still requires the +device token plus the pinned-key E2EE handshake. This limits idle unauthenticated +resource use but does not make the public hostname undiscoverable or replace +provider-side traffic controls. + +## User flow + +1. The operator configures a tunnel hostname that forwards WebSocket upgrades + to the Orca runtime port. +2. In Mobile pairing, the operator chooses **Add custom endpoint…** and enters a + value such as `wss://orca.example.com`. +3. Orca generates the existing mobile-scoped pairing QR with that endpoint. +4. The phone scans the QR and authenticates through the existing direct pairing + path. If Orca Relay is enabled, the existing direct-versus-Relay race remains + unchanged. + +The operator-facing Cloudflare route, port mapping, Access limitation, and +verification steps live in +[Mobile pairing through a public tunnel](./mobile-public-tunnel.md). + +## Verification + +The first implementation must prove: + +- parser acceptance for existing address forms and secure WebSocket origins; +- rejection of credentials, paths, query strings, fragments, invalid ports, + coercive numeric hosts, and unsupported schemes; +- Mobile picker submission of the exact `wss://` endpoint; +- Mobile pairing submission of that endpoint to the direct RPC client; +- Mobile RPC construction of a WebSocket with that endpoint unchanged; +- runtime pairing-offer preservation of the secure endpoint; +- Mobile host editing preservation of an unchanged implicit `wss://` port; +- no changes to pairing scope, device token, pinned key, or Relay metadata; +- root and Mobile typecheck, lint, formatting, and focused tests. + +Physical-device validation for the public Tunnel path was completed on a +physical Android device over cellular on 2026-07-16; the pairing flow reached +the E2EE-ready state and the desktop showed the paired device. iOS coverage, +connector-restart recovery, and private WARP routing remain unverified. The +private WARP path over an insecure `ws://` endpoint also needs a separate iOS +ATS check because the current exceptions are scoped to local networking and +Tailscale ranges. + +## Follow-up + +Connection-path labels currently distinguish only LAN, Tailscale, and Orca +Relay. A later provider-neutral change can represent a public secure direct +endpoint without labeling it as LAN. That change should remain separate from +endpoint acceptance so it can define persistence and migration semantics +without expanding this pairing-input slice. + +## Local review outcome + +The implementation review found one cross-flow defect before handoff: a paired +`wss://host` endpoint uses the implicit port 443, but the Mobile host editor +previously treated a missing saved port as 6768. A name-only edit could +therefore rewrite the endpoint and force a failed reconnect. The fix now +preserves an unchanged endpoint exactly and uses the current WebSocket scheme's +effective port when the hostname changes. This logic lives in +`mobile/src/transport/host-endpoint-edit.ts` so the existing endpoint parser +stays below the Mobile max-lines limit. + +Local evidence on 2026-07-16: + +- focused root tests: 59 passed; +- complete root test suite passed; +- complete Mobile tests: 1,771 passed and 2 skipped; +- root and Mobile typechecks passed; +- root and Mobile lint passed; +- root build passed; `build:unpack` reached the Electron download step but was + stopped because the uncached download was not completing at a practical rate; +- Mobile and touched-root formatting passed; +- localization catalog, localization coverage, reliability manifest, and + max-lines ratchet checks passed. + +No blocking code-review findings remain. A physical Android connection over a +real public tunnel is verified by the attached PR evidence; iOS, connector +restart recovery, and private WARP routing remain unverified. diff --git a/docs/reference/mobile-public-tunnel.md b/docs/reference/mobile-public-tunnel.md new file mode 100644 index 00000000000..9f935a00c25 --- /dev/null +++ b/docs/reference/mobile-public-tunnel.md @@ -0,0 +1,142 @@ +# Mobile pairing through a public tunnel + +Use a public tunnel when Orca Mobile must reach the desktop runtime from a +network that cannot route directly to the desktop. The public endpoint must be +a WebSocket origin such as `wss://orca.example.com`; paths, query strings, and +embedded credentials are not supported. + +This guide uses Cloudflare Tunnel as the concrete example. Orca does not +install, configure, start, or monitor `cloudflared`, and it does not store +Cloudflare credentials. + +## Port mapping + +The public and origin ports are different: + +```text +Orca Mobile + -> wss://orca.example.com:443 + -> Cloudflare edge TLS termination + -> cloudflared + -> http://127.0.0.1:6768 + -> Orca Mobile RPC runtime +``` + +- `443` is the implicit public port for `wss://`. +- `6768` is Orca's default local Mobile RPC port. +- The Cloudflare service type is **HTTP**, not TCP. The origin is a plain + HTTP/WebSocket service; Cloudflare provides TLS on the public side. +- Use the actual runtime port if Orca was started with a different port. + +## Cloudflare Tunnel route + +Create or select a Tunnel whose `cloudflared` connector runs on the same +computer as Orca. Add a published application route with these values: + +| Field | Value | +| --------------- | ----------------------------------------------- | +| Public hostname | A dedicated hostname such as `orca.example.com` | +| Path | Leave empty | +| Service type | `HTTP` | +| Service URL | `http://127.0.0.1:6768` | + +Prefer `127.0.0.1` over `localhost` when Orca is listening only on IPv4. This +avoids a connector resolving `localhost` to `::1` and failing to reach the +runtime. + +For a locally managed Tunnel, the equivalent ingress rule is: + +```yaml +tunnel: +credentials-file: /path/to/.json + +ingress: + - hostname: orca.example.com + service: http://127.0.0.1:6768 + - service: http_status:404 +``` + +Keep the Tunnel token, account certificate, and credentials file outside the +repository. Rotate the Tunnel token if it is exposed. + +Cloudflare documents the current dashboard and route fields in +[Set up Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/setup/) and +the HTTP service behavior in +[Protocols for published applications](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/routing-to-tunnel/protocols/). + +## Cloudflare Access limitation + +Do not protect this hostname with a Cloudflare Access policy for the current +implementation. Access expects a browser authorization cookie, OAuth flow, or +service-token headers. Orca Mobile currently opens the endpoint directly with +`new WebSocket(endpoint)` and does not provide those credentials. + +Do not put a Cloudflare service token in the pairing URL. Supporting Access +requires a separate credential storage, rotation, revocation, and request-header +design. Cloudflare's current service-token contract is documented in +[Service tokens](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/). + +The public hostname is still protected by Orca's device token, pinned desktop +public key, and E2EE handshake. Treat the pairing QR or pairing URL as a secret. + +## Configure Orca pairing + +1. Start Orca and confirm the Mobile RPC runtime is listening on port `6768`. +2. In **Settings > Mobile**, choose **Add custom endpoint…**. +3. Enter `wss://orca.example.com`. Port `443` may be explicit but is not + required. +4. Generate the pairing QR code and scan it in Orca Mobile. +5. Use **Direct only** to verify the Tunnel path by itself. Automatic mode may + race the same direct endpoint against Orca Relay. + +## Verification + +Check local HTTP reachability first: + +```bash +curl --fail --show-error --max-time 10 http://127.0.0.1:6768/ +``` + +Then check the public hostname: + +```bash +curl --fail --show-error --max-time 20 https://orca.example.com/ +``` + +Verify that Cloudflare forwards WebSocket upgrades: + +```bash +curl --http1.1 --include --max-time 10 \ + --header 'Connection: Upgrade' \ + --header 'Upgrade: websocket' \ + --header 'Sec-WebSocket-Version: 13' \ + --header 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + https://orca.example.com/ +``` + +The WebSocket check must return `101 Switching Protocols`. A successful `101` +proves network and upgrade reachability; it does not prove Orca pairing, +device-token authorization, or E2EE. + +Complete the end-to-end check on a physical phone: + +1. Disable Wi-Fi so the phone uses cellular data. +2. Pair through the `wss://` endpoint and open a worktree or terminal. +3. Background and foreground the app and verify it reconnects. +4. Restart the Tunnel connector and verify the Mobile client recovers after the + connector returns. +5. Repeat on iOS and Android before treating the feature as fully verified. + +## Troubleshooting + +- `502 Bad Gateway`: compare the Tunnel service URL with the port Orca is + actually listening on. Check `cloudflared` logs for `connection refused`. +- `403`, an Access login page, or an HTTP redirect during WebSocket setup: + remove the Access policy from this hostname; Access authentication is not yet + supported by Orca Mobile. +- HTTPS succeeds but the WebSocket check does not return `101`: confirm the + route uses service type HTTP, has no path restriction, and reaches the Orca + runtime rather than another local service. +- The hostname works on the desktop but not the phone: test on cellular, check + public DNS and the certificate, and confirm the pairing endpoint begins with + `wss://` rather than `ws://`. diff --git a/mobile/README.md b/mobile/README.md index 576e1efe9be..ae6630b9869 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -15,7 +15,8 @@ Unless a command says otherwise, run mobile app commands from the `mobile/` dire - pnpm - Xcode and/or Android Studio tooling for simulator or device builds - Expo Go on your phone, or a development client build when native modules are needed -- Phone and desktop on the same LAN when testing a physical phone +- Phone and desktop on the same LAN, or connected through Tailscale or a public + tunnel, when testing a physical phone ## Start Desktop Orca @@ -61,6 +62,13 @@ pnpm start --dev-client For the Android emulator, use `ws://10.0.2.2:6768`. For a physical phone, use the desktop LAN IP, for example `ws://192.168.0.179:6768`. +For a TLS-terminated public tunnel, choose **Add custom endpoint…** and enter a +WebSocket origin such as `wss://orca.example.com`. Cloudflare Tunnel must map +that public hostname on port `443` to the desktop runtime at +`http://127.0.0.1:6768`. See +[Mobile pairing through a public tunnel](../docs/reference/mobile-public-tunnel.md) +for the Cloudflare route, security boundary, and verification steps. + If the phone has a stale host entry, remove it from the app and pair again. ## Development Paths diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx index 790c755cd47..9daff4ec97e 100644 --- a/mobile/app/h/[hostId]/edit.tsx +++ b/mobile/app/h/[hostId]/edit.tsx @@ -15,12 +15,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store' -import { - displayHostEndpoint, - endpointPort, - endpointScheme, - normalizeHostEndpoint -} from '../../../src/transport/host-endpoint' +import { displayHostEndpoint, normalizeHostEndpoint } from '../../../src/transport/host-endpoint' +import { normalizeEditedHostEndpoint } from '../../../src/transport/host-endpoint-edit' import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context' import type { HostProfile } from '../../../src/transport/types' @@ -68,12 +64,10 @@ export default function EditHostScreen() { void load() }, [load]) - const fallbackPort = host ? endpointPort(host.endpoint) : undefined - const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws' - const normalizedEndpoint = useMemo( - () => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }), - [address, fallbackPort, fallbackScheme] + () => + host ? normalizeEditedHostEndpoint(address, host.endpoint) : normalizeHostEndpoint(address), + [address, host] ) const nameTrimmed = name.trim() @@ -243,8 +237,8 @@ export default function EditHostScreen() { }} /> - Accepts IP, host:port, or ws:// / wss://. Missing port defaults to the current port - (or 6768). + Accepts IP, host:port, or ws:// / wss://. Missing port keeps the current endpoint's + scheme and effective port. {normalizedEndpoint.ok ? ( diff --git a/mobile/src/transport/host-endpoint-edit.test.ts b/mobile/src/transport/host-endpoint-edit.test.ts new file mode 100644 index 00000000000..ef0dde64a40 --- /dev/null +++ b/mobile/src/transport/host-endpoint-edit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { normalizeEditedHostEndpoint } from './host-endpoint-edit' + +describe('normalizeEditedHostEndpoint', () => { + it('preserves an unchanged endpoint with an implicit WebSocket port', () => { + expect(normalizeEditedHostEndpoint('desk.example', 'wss://desk.example')).toEqual({ + ok: true, + endpoint: 'wss://desk.example' + }) + expect(normalizeEditedHostEndpoint('desk.example', 'ws://desk.example')).toEqual({ + ok: true, + endpoint: 'ws://desk.example' + }) + }) + + it('uses the current scheme default when editing an implicit-port endpoint', () => { + expect(normalizeEditedHostEndpoint('new.example', 'wss://desk.example')).toEqual({ + ok: true, + endpoint: 'wss://new.example:443' + }) + expect(normalizeEditedHostEndpoint('new.example', 'ws://desk.example')).toEqual({ + ok: true, + endpoint: 'ws://new.example:80' + }) + }) +}) diff --git a/mobile/src/transport/host-endpoint-edit.ts b/mobile/src/transport/host-endpoint-edit.ts new file mode 100644 index 00000000000..4d5a283dba9 --- /dev/null +++ b/mobile/src/transport/host-endpoint-edit.ts @@ -0,0 +1,32 @@ +import { + displayHostEndpoint, + endpointPort, + normalizeHostEndpoint, + type NormalizeHostEndpointResult +} from './host-endpoint' + +export function normalizeEditedHostEndpoint( + input: string, + currentEndpoint: string +): NormalizeHostEndpointResult { + let currentScheme: 'ws' | 'wss' + try { + const protocol = new URL(currentEndpoint).protocol + if (protocol !== 'ws:' && protocol !== 'wss:') { + return normalizeHostEndpoint(input) + } + currentScheme = protocol === 'wss:' ? 'wss' : 'ws' + } catch { + return normalizeHostEndpoint(input) + } + + const trimmed = input.trim() + // Why: URL parsing hides default ports. Preserve an unchanged endpoint so a + // name-only edit cannot rewrite wss://host to wss://host:6768 and reconnect. + if (trimmed === displayHostEndpoint(currentEndpoint)) { + return { ok: true, endpoint: currentEndpoint } + } + + const fallbackPort = endpointPort(currentEndpoint) ?? (currentScheme === 'wss' ? '443' : '80') + return normalizeHostEndpoint(trimmed, { fallbackPort, fallbackScheme: currentScheme }) +} diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index 782c02aed50..7d46962d6e1 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -139,6 +139,30 @@ describe('pre-profile pairing coordinator', () => { expect(events).toEqual(['connect', 'save-host']) }) + it('passes a secure public-tunnel endpoint unchanged to the direct client', async () => { + const events: string[] = [] + const client = fakeClient([success({ version: '1.0.0' })]) + const deps = dependencies(client, events) + const secureOffer = { ...directOffer, endpoint: 'wss://orca.example.com' } + + const attempt = startPreProfilePairing({ + offer: secureOffer, + timeoutMs: 5_000, + dependencies: deps + }) + + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + expect(deps.connectDirect).toHaveBeenCalledWith( + secureOffer.endpoint, + secureOffer.deviceToken, + secureOffer.publicKeyB64, + undefined + ) + expect(deps.saveHost).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: secureOffer.endpoint }) + ) + }) + it('reuses the existing host id and name when re-pairing the same desktop key (no duplicate)', async () => { // STA-1840: re-pairing a desktop already stored under a different id must // merge into that card, not mint a new host-${now} and duplicate the row. diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index b2adb18c4ce..9e9bbfdabab 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -153,6 +153,14 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + it('constructs a secure public-tunnel WebSocket without rewriting its endpoint', () => { + const client = connect('wss://orca.example.com', 'token', 'server-key') + + expect(mockSockets[0]?.endpoint).toBe('wss://orca.example.com') + + client.close() + }) + it('ignores stale socket opens after reconnect swaps in a new socket', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const firstSocket = mockSockets[0]! diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 03c8225b44d..dd2e2609dcf 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -501,6 +501,36 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('preserves secure public-tunnel endpoints in mobile pairing offers', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = await server.createMobilePairingOffer({ + address: 'wss://orca.example.com', + connectionMode: 'local-only', + name: 'Public tunnel test' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(offer.endpoint).toBe('wss://orca.example.com') + expect(parsePairingCode(offer.pairingUrl)).toMatchObject({ + endpoint: 'wss://orca.example.com', + scope: 'mobile' + }) + } finally { + await server.stop() + } + }) + it('adds only the exact optional relay object to GUI mobile pairing offers', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index 18aa57faeaf..9f748f22981 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -1,16 +1,15 @@ import React, { useMemo } from 'react' import { translate } from '@/i18n/i18n' import { AddressPicker, type AddressOption } from '../network/AddressPicker' -import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' +import { parseMobilePairingEndpoint } from '../../../../shared/network/mobile-pairing-endpoint' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' // Why: MobileHero (mobile pairing screen) and MobilePairingSetupSection // (Settings → Mobile) both need the same network selector. This wraps the -// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 -// hostname — including Tailscale *.ts.net and DDNS domains — optionally -// with :port) and copy. Discovered interfaces come from the OS; "Add custom -// address…" opens a dialog for an address the OS didn't surface — the only -// way to pair across networks. +// generic AddressPicker with the mobile grammar (IPv4, RFC 1123 hostname, +// optional port, or a ws(s) origin) and copy. Discovered interfaces come from +// the OS; "Add custom endpoint…" is the path for addresses and user-managed +// secure tunnels the OS cannot surface. export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] @@ -55,7 +54,7 @@ export function NetworkInterfacePicker({ } addCustomLabel={translate( 'auto.components.mobile.NetworkInterfacePicker.add-custom', - 'Add custom address…' + 'Add custom endpoint…' )} placeholder={translate( 'auto.components.settings.MobileNetworkInterfaceSection.b2c384cfd6', @@ -67,26 +66,26 @@ export function NetworkInterfacePicker({ )} customInputId="custom-network-address-input" validateCustom={(input) => { - const parsed = parseManualNetworkAddress(input) - return parsed.ok ? { ok: true, value: parsed.address } : { ok: false } + const parsed = parseMobilePairingEndpoint(input) + return parsed.ok ? { ok: true, value: parsed.endpoint } : { ok: false } }} customDialogCopy={{ title: translate( 'auto.components.mobile.CustomNetworkAddressDialog.title', - 'Custom network address' + 'Custom direct endpoint' ), description: translate( 'auto.components.mobile.CustomNetworkAddressDialog.description', - 'Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.' + 'Advertise an IP address, hostname, or secure WebSocket tunnel that your phone can reach.' ), inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'), placeholder: translate( 'auto.components.mobile.CustomNetworkAddressDialog.placeholder', - 'my-mac.ts.net, home.example.com, or 192.168.1.50' + 'wss://orca.example.com, my-mac.ts.net, or 192.168.1.50' ), hint: translate( 'auto.components.mobile.CustomNetworkAddressDialog.hint', - 'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.' + 'Enter an IP address, hostname, optional :port, or a ws(s):// origin without a path.' ), cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'), confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address') diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx index cce8bdce341..58b0c0e8e84 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -47,7 +47,7 @@ describe('MobilePairingSetupSection', () => { it('keeps local settings visible for local-only pairing', () => { renderSection() expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') - expect(screen.getByText(/connects only through the local network address/i)).toBeVisible() + expect(screen.getByText(/connects only through the direct endpoint/i)).toBeVisible() }) it('keeps local settings visible for automatic direct-first pairing', () => { @@ -65,6 +65,15 @@ describe('MobilePairingSetupSection', () => { expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24') }) + it('commits a secure public-tunnel endpoint', async () => { + const { user, onSelectedAddressChange } = renderSection() + await user.click(screen.getByRole('combobox')) + await user.click(screen.getByRole('option', { name: 'Add custom endpoint…' })) + await user.type(screen.getByLabelText('Address'), 'wss://orca.example.com') + await user.click(screen.getByRole('button', { name: 'Use address' })) + expect(onSelectedAddressChange).toHaveBeenCalledWith('wss://orca.example.com') + }) + it('generates a pairing code with the selected mode', async () => { const { user, onGenerateQr } = renderSection() await user.click(screen.getByRole('button', { name: 'Generate QR Code' })) diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index caf1f6b6873..8c1534cb498 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -48,7 +48,7 @@ export function MobilePairingSetupSection({ ) : translate( 'auto.components.settings.MobilePairingSetupSection.localDescription', - 'The pairing code connects only through the local network address below.' + 'The pairing code connects only through the direct endpoint below.' )}

{relayConnectionControl}
@@ -78,13 +78,13 @@ export function MobilePairingSetupSection({

{translate( 'auto.components.settings.MobilePairingSetupSection.localSettings', - 'Local connection settings' + 'Direct connection settings' )}

{translate( 'auto.components.settings.MobilePairingSetupSection.localAddressDescription', - 'Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.' + 'Choose a LAN or private-network address, or advertise a secure WebSocket tunnel endpoint.' )}

diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5fa8b146a61..30a5c2a35bd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9075,11 +9075,11 @@ "MobilePairingSetupSection": { "title": "Pair a phone", "automaticDescription": "The pairing code includes direct access and encrypted Orca Relay fallback.", - "localDescription": "The pairing code connects only through the local network address below.", + "localDescription": "The pairing code connects only through the direct endpoint below.", "regenerate": "Regenerate", "generate": "Generate QR Code", - "localSettings": "Local connection settings", - "localAddressDescription": "Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.", + "localSettings": "Direct connection settings", + "localAddressDescription": "Choose a LAN or private-network address, or advertise a secure WebSocket tunnel endpoint.", "refresh": "Refresh network interfaces", "tailnet": "Connect with your own tailnet", "tailnetDescription": "Install Tailscale on this computer and your phone, sign in to the same tailnet, then select its 100.x.y.z address above.", @@ -10690,18 +10690,18 @@ } }, "CustomNetworkAddressDialog": { - "title": "Custom network address", - "description": "Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.", + "title": "Custom direct endpoint", + "description": "Advertise an IP address, hostname, or secure WebSocket tunnel that your phone can reach.", "label": "Address", - "placeholder": "my-mac.ts.net, home.example.com, or 192.168.1.50", - "hint": "Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.", + "placeholder": "wss://orca.example.com, my-mac.ts.net, or 192.168.1.50", + "hint": "Enter an IP address, hostname, optional :port, or a ws(s):// origin without a path.", "cancel": "Cancel", "use": "Use address" }, "NetworkInterfacePicker": { "trigger-label": "Network address to advertise", "custom-option": "{{address}} (custom)", - "add-custom": "Add custom address…" + "add-custom": "Add custom endpoint…" }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 45e1afec7a4..11daf6e26ac 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9075,11 +9075,11 @@ "MobilePairingSetupSection": { "title": "Vincular un teléfono", "automaticDescription": "El código de vinculación incluye acceso directo y Orca Relay cifrado como respaldo.", - "localDescription": "El código de vinculación solo se conecta mediante la dirección de red local indicada abajo.", + "localDescription": "El código de vinculación solo se conecta mediante el endpoint directo indicado abajo.", "regenerate": "Regenerar", "generate": "Generar código QR", - "localSettings": "Configuración de conexión local", - "localAddressDescription": "Elige la dirección LAN o de red privada que Orca Mobile puede usar para acceder directamente a este ordenador.", + "localSettings": "Configuración de conexión directa", + "localAddressDescription": "Elige una dirección LAN o de red privada, o anuncia un endpoint de túnel WebSocket seguro.", "refresh": "Actualizar interfaces de red", "tailnet": "Conectarse con tu propia tailnet", "tailnetDescription": "Instala Tailscale en este ordenador y en tu teléfono, inicia sesión en la misma tailnet y selecciona arriba su dirección 100.x.y.z.", @@ -10690,18 +10690,18 @@ } }, "CustomNetworkAddressDialog": { - "title": "Dirección de red personalizada", - "description": "Anuncia una dirección que tu teléfono pueda alcanzar cuando no esté en la misma red Wi-Fi: por ejemplo, un nombre de host de Tailscale o una IP estática.", + "title": "Endpoint directo personalizado", + "description": "Anuncia una dirección IP, un nombre de host o un túnel WebSocket seguro al que pueda acceder tu teléfono.", "label": "Dirección", - "placeholder": "my-mac.ts.net, home.example.com o 192.168.1.50", - "hint": "Introduce una dirección IP o un nombre de host (un nombre MagicDNS de Tailscale, un dominio DDNS o un nombre de host de LAN), opcionalmente con :puerto.", + "placeholder": "wss://orca.example.com, my-mac.ts.net o 192.168.1.50", + "hint": "Introduce una dirección IP, un nombre de host, un :puerto opcional o un origen ws(s):// sin ruta.", "cancel": "Cancelar", "use": "Usar dirección" }, "NetworkInterfacePicker": { "trigger-label": "Dirección de red para anunciar", "custom-option": "{{address}} (personalizada)", - "add-custom": "Añadir dirección personalizada…" + "add-custom": "Añadir endpoint personalizado…" }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c454ec9ce47..e5d8b4d46d7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9075,11 +9075,11 @@ "MobilePairingSetupSection": { "title": "スマートフォンをペアリング", "automaticDescription": "ペアリングコードには、直接接続と暗号化されたOrca Relayのフォールバックが含まれます。", - "localDescription": "ペアリングコードは、下のローカルネットワークアドレスだけを使用します。", + "localDescription": "ペアリングコードは、下の直接接続エンドポイントだけを使用します。", "regenerate": "再生成", "generate": "QRコードを生成", - "localSettings": "ローカル接続設定", - "localAddressDescription": "Orca Mobileからこのコンピューターへ直接接続できるLANまたはプライベートネットワークのアドレスを選択します。", + "localSettings": "直接接続設定", + "localAddressDescription": "LANまたはプライベートネットワークのアドレスを選択するか、安全なWebSocketトンネルのエンドポイントを指定します。", "refresh": "ネットワークインターフェースを更新", "tailnet": "自分のtailnetで接続", "tailnetDescription": "このコンピューターとスマートフォンにTailscaleをインストールし、同じtailnetにサインインして、上で100.x.y.zアドレスを選択します。", @@ -10690,18 +10690,18 @@ } }, "CustomNetworkAddressDialog": { - "title": "カスタムネットワークアドレス", - "description": "同じ Wi-Fi に接続していないときにスマートフォンから到達できるアドレスを指定します。たとえば Tailscale のホスト名や静的 IP です。", + "title": "カスタム直接接続エンドポイント", + "description": "スマートフォンから到達できるIPアドレス、ホスト名、または安全なWebSocketトンネルを指定します。", "label": "アドレス", - "placeholder": "my-mac.ts.net、home.example.com、または 192.168.1.50", - "hint": "IP アドレスまたはホスト名(Tailscale の MagicDNS 名、DDNS ドメイン、LAN のホスト名など)を入力してください。必要に応じて :ポート を付けられます。", + "placeholder": "wss://orca.example.com、my-mac.ts.net、または192.168.1.50", + "hint": "IPアドレス、ホスト名、任意の:ポート、またはパスのないws(s)://オリジンを入力します。", "cancel": "キャンセル", "use": "アドレスを使用" }, "NetworkInterfacePicker": { "trigger-label": "通知するネットワークアドレス", "custom-option": "{{address}}(カスタム)", - "add-custom": "カスタムアドレスを追加…" + "add-custom": "カスタムエンドポイントを追加…" }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b1f7cca3488..52646de8ace 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9075,11 +9075,11 @@ "MobilePairingSetupSection": { "title": "휴대폰 페어링", "automaticDescription": "페어링 코드에는 직접 연결과 암호화된 Orca Relay 대체 연결이 포함됩니다.", - "localDescription": "페어링 코드는 아래의 로컬 네트워크 주소로만 연결합니다.", + "localDescription": "페어링 코드는 아래의 직접 연결 엔드포인트로만 연결합니다.", "regenerate": "다시 생성", "generate": "QR 코드 생성", - "localSettings": "로컬 연결 설정", - "localAddressDescription": "Orca Mobile에서 이 컴퓨터에 직접 연결할 수 있는 LAN 또는 사설 네트워크 주소를 선택하세요.", + "localSettings": "직접 연결 설정", + "localAddressDescription": "LAN 또는 사설 네트워크 주소를 선택하거나 안전한 WebSocket 터널 엔드포인트를 지정하세요.", "refresh": "네트워크 인터페이스 새로 고침", "tailnet": "내 tailnet으로 연결", "tailnetDescription": "이 컴퓨터와 휴대폰에 Tailscale을 설치하고 같은 tailnet에 로그인한 다음 위에서 100.x.y.z 주소를 선택하세요.", @@ -10690,18 +10690,18 @@ } }, "CustomNetworkAddressDialog": { - "title": "사용자 지정 네트워크 주소", - "description": "같은 Wi-Fi에 있지 않을 때 휴대폰이 연결할 수 있는 주소를 알립니다. 예: Tailscale 호스트 이름 또는 고정 IP.", + "title": "사용자 지정 직접 연결 엔드포인트", + "description": "휴대폰이 연결할 수 있는 IP 주소, 호스트 이름 또는 안전한 WebSocket 터널을 지정합니다.", "label": "주소", - "placeholder": "my-mac.ts.net, home.example.com 또는 192.168.1.50", - "hint": "IP 주소 또는 호스트 이름(Tailscale MagicDNS 이름, DDNS 도메인 또는 LAN 호스트 이름)을 입력하세요. 필요하면 :포트를 붙일 수 있습니다.", + "placeholder": "wss://orca.example.com, my-mac.ts.net 또는 192.168.1.50", + "hint": "IP 주소, 호스트 이름, 선택적 :포트 또는 경로가 없는 ws(s):// 오리진을 입력하세요.", "cancel": "취소", "use": "주소 사용" }, "NetworkInterfacePicker": { "trigger-label": "알릴 네트워크 주소", "custom-option": "{{address}} (사용자 지정)", - "add-custom": "사용자 지정 주소 추가…" + "add-custom": "사용자 지정 엔드포인트 추가…" }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1e59503c12c..7ba969a3d5f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9075,11 +9075,11 @@ "MobilePairingSetupSection": { "title": "配对手机", "automaticDescription": "配对码包含直接访问方式和加密的 Orca Relay 备用连接。", - "localDescription": "配对码仅通过下方的本地网络地址连接。", + "localDescription": "配对码仅通过下方的直接连接端点连接。", "regenerate": "重新生成", "generate": "生成二维码", - "localSettings": "本地连接设置", - "localAddressDescription": "选择 Orca Mobile 可用于直接访问此电脑的局域网或专用网络地址。", + "localSettings": "直接连接设置", + "localAddressDescription": "选择局域网或专用网络地址,或公布安全的 WebSocket 隧道端点。", "refresh": "刷新网络接口", "tailnet": "使用自己的 tailnet 连接", "tailnetDescription": "在此电脑和手机上安装 Tailscale,登录同一个 tailnet,然后在上方选择其 100.x.y.z 地址。", @@ -10690,18 +10690,18 @@ } }, "CustomNetworkAddressDialog": { - "title": "自定义网络地址", - "description": "指定当手机不在同一 Wi-Fi 时可以访问的地址,例如 Tailscale 主机名或静态 IP。", + "title": "自定义直接连接端点", + "description": "公布手机可访问的 IP 地址、主机名或安全 WebSocket 隧道。", "label": "地址", - "placeholder": "my-mac.ts.net、home.example.com 或 192.168.1.50", - "hint": "请输入 IP 地址或主机名(Tailscale MagicDNS 名称、DDNS 域名或 LAN 主机名),可选择附加 :端口。", + "placeholder": "wss://orca.example.com、my-mac.ts.net 或 192.168.1.50", + "hint": "输入 IP 地址、主机名、可选的 :端口,或不含路径的 ws(s):// URL。", "cancel": "取消", "use": "使用地址" }, "NetworkInterfacePicker": { "trigger-label": "要公布的网络地址", "custom-option": "{{address}}(自定义)", - "add-custom": "添加自定义地址…" + "add-custom": "添加自定义端点…" }, "WindowsFirewallNotice": { "repair-success": "Windows 防火墙现已允许 Orca Mobile 在专用网络上通信", diff --git a/src/shared/network/mobile-pairing-endpoint.test.ts b/src/shared/network/mobile-pairing-endpoint.test.ts new file mode 100644 index 00000000000..f5cb1406cf1 --- /dev/null +++ b/src/shared/network/mobile-pairing-endpoint.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { parseMobilePairingEndpoint } from './mobile-pairing-endpoint' + +describe('parseMobilePairingEndpoint', () => { + it('preserves existing address forms', () => { + expect(parseMobilePairingEndpoint('192.168.1.24')).toEqual({ + ok: true, + endpoint: '192.168.1.24' + }) + expect(parseMobilePairingEndpoint('my-mac.ts.net')).toEqual({ + ok: true, + endpoint: 'my-mac.ts.net' + }) + expect(parseMobilePairingEndpoint('home.example.com:8443')).toEqual({ + ok: true, + endpoint: 'home.example.com:8443' + }) + }) + + it('accepts WebSocket origins for public tunnels', () => { + expect(parseMobilePairingEndpoint('wss://orca.example.com')).toEqual({ + ok: true, + endpoint: 'wss://orca.example.com' + }) + expect(parseMobilePairingEndpoint('ws://private-host:6768/')).toEqual({ + ok: true, + endpoint: 'ws://private-host:6768' + }) + }) + + it('trims surrounding whitespace', () => { + expect(parseMobilePairingEndpoint(' wss://orca.example.com ')).toEqual({ + ok: true, + endpoint: 'wss://orca.example.com' + }) + }) + + it('rejects unsupported schemes and WebSocket credentials', () => { + for (const value of [ + 'https://orca.example.com', + 'ftp://orca.example.com', + 'wss://user@orca.example.com', + 'wss://user:secret@orca.example.com' + ]) { + expect(parseMobilePairingEndpoint(value).ok).toBe(false) + } + }) + + it('rejects paths, queries, and fragments', () => { + for (const value of [ + 'wss://orca.example.com/orca', + 'wss://orca.example.com?token=value', + 'wss://orca.example.com#fragment' + ]) { + expect(parseMobilePairingEndpoint(value).ok).toBe(false) + } + }) + + it('rejects invalid ports and coercive numeric hosts', () => { + for (const value of [ + 'wss://orca.example.com:0', + 'wss://orca.example.com:65536', + 'wss://127.1', + 'wss://foo.123', + 'wss://[::1]:6768' + ]) { + expect(parseMobilePairingEndpoint(value).ok).toBe(false) + } + }) +}) diff --git a/src/shared/network/mobile-pairing-endpoint.ts b/src/shared/network/mobile-pairing-endpoint.ts new file mode 100644 index 00000000000..534df74c064 --- /dev/null +++ b/src/shared/network/mobile-pairing-endpoint.ts @@ -0,0 +1,55 @@ +import { parseManualNetworkAddress } from './manual-address' + +const ERROR_MESSAGE = 'Enter an IP address, hostname, or ws(s):// endpoint, optionally with a port' +const SCHEME_PREFIX = /^[a-z][a-z0-9+.-]*:\/\//i +const WEBSOCKET_SCHEME_PREFIX = /^wss?:\/\//i + +export type ParseMobilePairingEndpointResult = + | { ok: true; endpoint: string } + | { ok: false; error: string } + +export function parseMobilePairingEndpoint(input: string): ParseMobilePairingEndpointResult { + const trimmed = input.trim() + if (!SCHEME_PREFIX.test(trimmed)) { + const parsed = parseManualNetworkAddress(trimmed) + return parsed.ok ? { ok: true, endpoint: parsed.address } : { ok: false, error: ERROR_MESSAGE } + } + + if (!WEBSOCKET_SCHEME_PREFIX.test(trimmed) || /\s/.test(trimmed)) { + return { ok: false, error: ERROR_MESSAGE } + } + + const authorityAndSuffix = trimmed.replace(WEBSOCKET_SCHEME_PREFIX, '') + const suffixIndex = authorityAndSuffix.search(/[/?#]/) + const authority = + suffixIndex === -1 ? authorityAndSuffix : authorityAndSuffix.slice(0, suffixIndex) + const suffix = suffixIndex === -1 ? '' : authorityAndSuffix.slice(suffixIndex) + + // Why: Mobile host editing persists origin-only endpoints. Keep pairing on + // the same grammar so a tunnel endpoint remains editable after it is saved. + if (authority.includes('@') || (suffix !== '' && suffix !== '/')) { + return { ok: false, error: ERROR_MESSAGE } + } + if (!parseManualNetworkAddress(authority).ok) { + return { ok: false, error: ERROR_MESSAGE } + } + + try { + const url = new URL(trimmed) + if ( + (url.protocol !== 'ws:' && url.protocol !== 'wss:') || + url.hostname === '' || + url.username !== '' || + url.password !== '' || + (url.pathname !== '' && url.pathname !== '/') || + url.search !== '' || + url.hash !== '' + ) { + return { ok: false, error: ERROR_MESSAGE } + } + } catch { + return { ok: false, error: ERROR_MESSAGE } + } + + return { ok: true, endpoint: suffix === '/' ? trimmed.slice(0, -1) : trimmed } +}