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:
{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 } +}