Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions docs/reference/2026-07-16-mobile-public-tunnel-pairing-design.md
Original file line number Diff line number Diff line change
@@ -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.
142 changes: 142 additions & 0 deletions docs/reference/mobile-public-tunnel.md
Original file line number Diff line number Diff line change
@@ -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: <TUNNEL-UUID>
credentials-file: /path/to/<TUNNEL-UUID>.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://`.
10 changes: 9 additions & 1 deletion mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
20 changes: 7 additions & 13 deletions mobile/app/h/[hostId]/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -243,8 +237,8 @@ export default function EditHostScreen() {
}}
/>
<Text style={styles.hint}>
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.
</Text>

{normalizedEndpoint.ok ? (
Expand Down
26 changes: 26 additions & 0 deletions mobile/src/transport/host-endpoint-edit.test.ts
Original file line number Diff line number Diff line change
@@ -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'
})
})
})
Loading
Loading