Skip to documentation
OrbitMeshDocs
Browse documentation
Docs/Engineering Specs

ENGINEERING SPECS

Client Mesh + Traffic Datapath (Mobile)

Design RFC. Not yet implemented. This document extends capability-model.md (the authoritative Traffic/Mesh/Gateway/Tunnel model) and client-architecture.md (the current Flutter...
View source

1. Status

Design RFC. Not yet implemented. This document extends capability-model.md (the authoritative Traffic/Mesh/Gateway/Tunnel model) and client-architecture.md (the current Flutter client). It specifies how the mobile client participates in Mesh and Traffic at the same time over a single OS VPN interface, so that a phone is a first-class, reachable mesh node — not a client-only egress — and can later have its local services exposed publicly through Tunnel.

Scope is the client datapath and the control-plane provisioning it depends on. It does not change the capability model or the gateway/tunnel runtimes; those are referenced where they connect.

2. Problem and Goals

Android/iOS allow exactly one VpnService/NetworkExtension VPN (one TUN) at a time. Today the client uses that slot for Traffic only (Mihomo behind a TUN via hev-socks5-tunnel). Running the Mesh runtime as a second VPN app is impossible without root, so connecting Traffic evicts Mesh and vice versa.

The product goal (per capability-model §5) is that the mobile device is a real Mesh node while also using Traffic policy routing, and that its local services can be published via Tunnel:

  • mesh.node / mesh.peer — reachable inbound from other tenant nodes, over TCP/UDP and ICMP, natively.
  • traffic.entry / traffic.route — Clash-style split routing for egress.
  • future tunnel.endpoint — a service on the phone (e.g. 127.0.0.1:PORT) is reachable via Mesh and published to a public endpoint by a Gateway/Tunnel node.

The "expose a mobile service through Mesh to Tunnel" requirement is the forcing function: the phone must be an inbound-reachable L3 node, which decides the datapath (§4) and rules out the userspace-SOCKS shortcut as the permanent design (§8).

3. Capability Alignment

Mesh runtime on the client is tailscale (client) against a self-hosted ionscale control plane (capability-model §5.2 lists tailscale as a Mesh runtime with optional Tunnel). ionscale provides multi-tailnet, pre-auth keys, tags, ACLs, and a management API — mapping cleanly onto OrbitMesh tenants and the device-scoped provisioning already used for subscriptions.

Client capability roles:

today   : traffic.entry, traffic.route
add     : mesh.node   (reachable, serving)
future  : tunnel.endpoint (a local service published via a Tunnel node)

4. Datapath: One TUN, Deep Shared

The client keeps its single VpnService TUN and demuxes packets by destination. Mesh traffic is handled by an embedded WireGuard engine whose decrypted packets are written back into the same TUN so the OS delivers them to local sockets natively (this is what makes the phone inbound-reachable, ICMP included). Everything else flows to Mihomo as today.

            ┌──────────────── OrbitMesh :core process ────────────────┐
 OS apps    │                                                          │
   │ egress │            read TUN fd                                   │
   ▼        │        ┌──────────────────┐                             │
 [ VpnService TUN ]──▶│  packet classifier│                            │
   ▲        │        └───────┬───────────┘                            │
   │ inject │      dst∈tailnet│        else (internet/LAN)             │
   │        │                 ▼                     ▼                  │
   │        │        ┌────────────────┐    ┌──────────────────┐       │
   │        │        │ mesh engine     │    │ tun2socks        │       │
   │        │        │ (wireguard-go + │    │ (feeds Mihomo    │       │
   │        │        │  tailscale      │    │  SOCKS, replaces │       │
   │        │        │  control/magic- │    │  hev)            │       │
   │        │        │  sock)          │    └────────┬─────────┘       │
   │        │        └──┬─────────▲────┘             │ 127.0.0.1:7890  │
   │  decrypted mesh    │ encrypt │ decrypt          ▼                 │
   └────────────────────┘  to     │           ┌──────────────┐        │
                          peer/DERP│           │ Mihomo (child │       │
                                   ▼           │ process,      │       │
                            [ protected UDP ]  │ policy engine)│       │
                            (VpnService.protect)└──────┬───────┘       │
                                   │                    │ outbounds     │
                                   │              direct│ / proxy node  │
                                   │                    │ / via-mesh-gw │
                                   └────────────────────┴───────────────┘
                                        underlying physical network

4.1 Packet classifier

The datapath owns the TUN fd and, per packet read:

  • dst ∈ 100.64.0.0/10 (or the tenant tailnet CIDR) and dst = 100.100.100.100 (MagicDNS) → the mesh engine;
  • replies from a local service to a mesh peer (dst ∈ tailnet) → mesh engine (same rule);
  • everything else → tun2socks → Mihomo.

Inbound decrypted mesh packets are written into the TUN with the device's tailnet IP as destination, so the kernel delivers them to whatever local socket is bound (e.g. adbd:5555, a dev server) — no per-port forwardTCPToLocal, no gVisor termination for OS-hosted services.

4.2 Mesh engine

Embeds tailscale.com (wgengine + magicsock + control client) as a Go library, given a virtual tun.Device that is a filtered view of the shared TUN (tailnet packets only). It talks to ionscale via --login-server. This replaces hev-socks5-tunnel with a custom Go datapath that hosts both the mesh engine and the tun2socks-to-Mihomo path; Mihomo stays a separate child process (no fork of Clash.Meta required).

4.3 Loop avoidance / socket protect

The mesh engine's own WireGuard/DERP UDP sockets must bypass the TUN or they loop. Because :core runs under the app uid and the VPN excludes its own uid, uid exclusion covers most cases; additionally the Go datapath must call VpnService.protect(fd) for its transport sockets via a JNI callback wired into the WireGuard bind hook.

4.4 DNS / MagicDNS

100.100.100.100:53 is just a tailnet-destined packet → handled by the mesh engine's MagicDNS responder. Mihomo's DNS hijack (fake-ip / port 53) must apply only to non-tailnet traffic; since the classifier routes tailnet packets away before Mihomo sees them, this holds as long as the client's resolver sends *.<tenant>.<magicdns-domain> queries to 100.100.100.100 (set on the TUN's DNS config). Split-DNS specifics are the fiddliest area and validated in Phase 2.

4.5 Traffic ⊂ Mesh (unify egress with gateways)

Add a Mihomo outbound type "via mesh gateway": egress through a mesh peer that holds mesh.exit_node / traffic.exit. This avoids maintaining a second overlay — proxy "nodes" and mesh gateways become the same peers, and Mihomo remains the policy brain choosing per-flow: direct / proxy-protocol node / via-mesh-gateway.

5. Control-Plane Provisioning

OrbitMesh control-plane is the single per-tenant IdP. It issues four credential classes, all scoped by tenant + durable device_installation_id (mirroring ensure-client-access, see device-bound-edge-runtime-identity.md):

Credential Layer Source Client storage
subscription URL traffic existing ensure-client-access SubscriptionBinding (secure)
mesh pre-auth key mesh control-plane → ionscale mgmt API MeshBinding (secure)
gateway enrollment gateway edge-runtime identity edge-runtime (existing)
tunnel publish token tunnel control-plane → tunnel runtime MeshBinding / dedicated

5.1 Mesh key issuance

A new control-plane endpoint (parallel to ensure-client-access) resolves, per tenant tailnet, an ephemeral, tagged ionscale pre-auth key for this device (ephemeral so mobile nodes are reaped when offline; tagged so ACLs apply). The device consumes it at connect and never persists it in plaintext.

5.2 MeshBinding (client)

New scoped secret, keyed apiUrl|userId|tenantId|deviceInstallationId, stored in flutter_secure_storage like SubscriptionBinding: loginServer, authKey (secret, AppLogger.registerSecret), tailnet, magicDnsDomain, nodeTags, createdAt. Cleared on sign-out; re-resolved on tenant switch. Logs redact via redactUrl/redactSecrets.

5.3 Isolation model

One tailnet per tenant (cleanest isolation); ionscale ACL (HuJSON) governs intra-tenant reachability. Node hostname is a redacted device label; nodes are tagged tag:orbitmesh-<tenant>.

6. Unified Policy Model

Three policy surfaces must stay coherent per tenant, or you get "reachable on Mesh but Traffic bypasses it" / "published but ACL denies it":

mesh ACL         (ionscale/Tailscale policy)  — who may reach whom
traffic rules    (traffic-policy-dsl)          — how a flow egresses
tunnel ingress   (tunnel runtime config)       — which services are public

The control-plane owns a tenant policy model compiled down to all three (ionscale ACL, Mihomo rules per traffic-policy-dsl.md, tunnel ingress). This RFC does not fix the DSL; it fixes the requirement that a single source compiles the three, so they cannot drift.

7. Tunnel Exposes a Mobile Service (future)

public client → public DNS/TLS → Gateway/Tunnel node (tunnel.endpoint)
              → mesh (WireGuard) → mobile mesh.node → 127.0.0.1:PORT (local service)

Requires: the mobile datapath of §4 (inbound-reachable), a service registration (mesh serve record) from the device, and an ingress binding on a Tunnel node. Because §4 delivers inbound packets to the OS stack natively, the local service needs no special integration — it just listens.

8. Alternatives Considered

Userspace-networking + SOCKS5 (mesh engine terminates tailnet in a gVisor netstack; Mihomo dials its SOCKS for tailnet CIDR). Simpler — ships tailscaled as an opaque binary, no shared-TUN datapath. Rejected as the permanent design because it makes the phone client-only: no native inbound (adb/serving needs per-port forwardTCPToLocal), no ICMP, weak UDP — which blocks §7. It remains useful only as (a) a throwaway PoC to validate §5 provisioning, and (b) a stop-gap that unblocks remote adb via a single-port forward.

9. Risks

  1. Datapath complexity (§4) is the largest client effort and the critical path for every mesh/inbound/serving capability.
  2. Version coupling: vendoring tailscale.com into the datapath ties client upgrades to ionscale-compatible tailscale versions — upgrades become code-change + rebuild, not a binary swap. Pin explicitly; track ionscale's supported range.
  3. Policy coherence (§6) is the long-term operational risk.
  4. Battery / background: an always-on full node (keepalives, DERP, serving) in :core costs more than a toggled proxy and must survive OS freezing — and it is the operator's remote lifeline, so robustness is non-negotiable.
  5. Self-lifeline bootstrap: this datapath is what keeps remote adb alive; a bad build can strand a physically-inaccessible device. Mitigations: install builds while the tunnel is off; keep the mesh path independent of Mihomo health; add a watchdog that fails safe to "mesh up, egress direct".

10. Milestones

  1. Control-plane wiring (no mobile datapath): ionscale key issuance endpoint, tenant→tailnet mapping, ACLs; validate with a desktop tailscale node + gateway exit + a Tunnel publish demo.
  2. Mobile PoC (SOCKS stop-gap): embed tailscaled userspace + single-port adb forward to prove provisioning and unblock remote dev. Explicitly throwaway datapath.
  3. Deep shared-TUN datapath: custom Go datapath (classifier + mesh engine on shared TUN + tun2socks→Mihomo), VpnService.protect hook, DNS split. Native inbound (adb/ICMP) works; phone is a real mesh.node.
  4. Traffic ⊂ Mesh: Mihomo "via mesh gateway" outbound (§4.5).
  5. Tunnel exposes mobile service (§7): service registration + ingress on a Tunnel node.

11. Open Questions

  • ionscale-supported tailscale version to pin (§9.2).
  • MagicDNS domain scheme per tenant (§4.4).
  • Always-on mesh vs. mesh-with-proxy-toggle default on mobile (battery vs. the remote-lifeline property of §9.5).
  • Tunnel publish authz model: per-service tokens vs. tenant ACL-derived.
OrbitMesh DocumentationContent follows the repository's main branch.