ENGINEERING SPECS
Client Mesh + Traffic Datapath (Mobile)
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) anddst = 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
- Datapath complexity (§4) is the largest client effort and the critical path for every mesh/inbound/serving capability.
- Version coupling: vendoring
tailscale.cominto 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. - Policy coherence (§6) is the long-term operational risk.
- Battery / background: an always-on full node (keepalives, DERP, serving)
in
:corecosts more than a toggled proxy and must survive OS freezing — and it is the operator's remote lifeline, so robustness is non-negotiable. - Self-lifeline bootstrap: this datapath is what keeps remote
adbalive; 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
- Control-plane wiring (no mobile datapath): ionscale key issuance endpoint,
tenant→tailnet mapping, ACLs; validate with a desktop
tailscalenode + gateway exit + a Tunnel publish demo. - Mobile PoC (SOCKS stop-gap): embed
tailscaleduserspace + single-portadbforward to prove provisioning and unblock remote dev. Explicitly throwaway datapath. - Deep shared-TUN datapath: custom Go datapath (classifier + mesh engine on
shared TUN + tun2socks→Mihomo),
VpnService.protecthook, DNS split. Native inbound (adb/ICMP) works; phone is a real mesh.node. - Traffic ⊂ Mesh: Mihomo "via mesh gateway" outbound (§4.5).
- Tunnel exposes mobile service (§7): service registration + ingress on a Tunnel node.
11. Open Questions
- ionscale-supported
tailscaleversion 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.