App Relay lets the mobile App reach a Ekko Studio instance without exposing
the Studio HTTP server to the Internet. It is independent from the MCU
/global-agent connection.
Studio can use the official https://api.ekkostudio.xyz route or the
Cloudflare https://cn.ekkostudio.xyz route. The selected route is persisted
locally and included in cloud QR codes as informational metadata. The App keeps
an independent route setting; scanning a cloud QR never changes it.
An authenticated Studio user creates a one-time LAN QR code through
POST /api/app-connections/authorization-codes/lan. The QR contains the local
backend URL, machine ID, a high-entropy authorization code, and its expiry. It
expires after five minutes, is stored only as a hash, records the user who
created it, and can be consumed once.
The App exchanges it through POST /api/auth/app-login together with its
stable installation device code, device name, brand, and model. Studio issues
a device-bound app_access token for the authorizing Studio user. App tokens
use the hermes-studio audience and expire after 30 days. Manual LAN login may
use an active Studio username and password instead of an authorization code.
An authenticated Studio user creates a cloud QR through
POST /api/app-connections/authorization-codes/cloud. Studio first signs into
the cloud /app-relay namespace with its Ed25519 machine identity, then asks
the cloud for a preconnection. The QR contains only:
{
"t": "hsac",
"v": 1,
"c": "cloud",
"m": "hwui_...",
"p": "uuid",
"k": "high-entropy secret",
"e": 0,
"r": "official"
}The compact keys represent type, version, connection type, machine ID, preconnection ID, matching code, expiry, and Studio network route respectively. Compact encoding reduces QR density without reducing matching-code entropy. The matching code expires after five minutes. Refresh has a ten-second cooldown and is limited to three times for a preconnection; every successful refresh invalidates the previous matching code. An unmatched host connection has an absolute 15-minute lifetime and is actively disconnected by the cloud. The Studio UI never automatically refreshes an expired LAN or cloud QR: it keeps the QR visible with an expired overlay until the user requests refresh.
The signed-in App sends the QR fields and its stable device identity to
POST /api/app/connections/claim. The cloud asks that exact Studio socket to
exchange the Studio-side one-time authorization code. Only after both sides
succeed does the cloud create or reactivate the formal connection.
The cloud stores physical phone installations in app_device and formal
phone-to-Studio relationships in app_device_connection. A formal connection
is unique by (appDeviceId, machineId), allowing one Studio to connect to many
phones and one phone to connect to many Studios. A device code is globally
bound to one App account.
Account limits are checked through dedicated entitlement hooks. They currently
return unlimited values; subscription or plan limits can later be added without
changing the connection tables or claim protocol. Physical-device limits must
count app_device, not connection rows.
Cloud App sockets require three independent credentials:
- the App account access token;
- the formal
connectionIdand App device code; - a random per-connection credential, stored only as a hash in the cloud.
The cloud derives machineId from the formal connection row instead of trusting
the App handshake. The local Studio user token remains separate and continues
to enforce normal Studio user/profile permissions on forwarded requests.
GET /api/app-connections reports live LAN presence from the local relay and
live cloud App presence from the cloud connection pool. Studio polls this list
while the page is visible.
The App uses GET /api/app/connections/:connectionId/status for an individual
cloud connection. The cloud verifies that the connection belongs to the
authenticated account, then reads machineOnline from the Studio host socket
pool and appOnline from the formal App-connection socket pool. Socket.IO
heartbeat loss removes the corresponding socket from the pool. The response is
not cacheable and also returns the Hermes Agent and Hermes Web UI versions from
the machine's latest signed registration metadata. This is a presence lookup,
not a new inbound request to the Studio machine.
Deleting a Studio connection creates a local revocation tombstone. LAN Apps are
notified and disconnected directly. Cloud deletions revoke the formal cloud
connection and disconnect its socket. An offline App is rejected with
app_connection_deleted on its next connection and removes the device after
showing a confirmation dialog.
If Studio has any active cloud App records, it connects to the cloud at startup.
Socket.IO reconnects indefinitely after transient disconnects. The cloud
restores the formal-connection snapshot; Studio reconciles it against local
revocation tombstones so an offline cloud deletion is eventually propagated.
Development Web UI hosts use a separate persistent Ed25519 identity and also
register as non-preemptive. Starting npm run dev therefore creates a distinct
Web endpoint instead of replacing the packaged desktop socket or overwriting its
machine metadata. Packaged production builds keep the legacy machine identity,
so existing desktop connections remain compatible. Production hosts retain the
existing takeover behavior so a restarted desktop can recover stale connections.
- HTTP RPC accepts Studio
/api/**and/healthpaths. - Request headers, methods, paths, and Socket.IO client events are allowlisted.
- Request and response bodies are capped at 20 MiB.
- Socket RPC accepts
/chat-runand/group-chatonly. - Every bridge is bound to one formal App connection and one host socket.
- Formal connection authorization is rechecked before each forwarded request.
LAN and cloud use the same App-facing RPC event shapes. Their transport paths are:
- LAN: App → Studio local relay → local HTTP or Socket.IO.
- Cloud: App → cloud relay → signed Studio host socket → local HTTP or Socket.IO.
The cloud writes structured JSON operational logs to stdout. Authentication
material, passwords, matching codes, authorization codes, and connection
credentials are redacted. Critical App-connection lifecycle events are also
stored in app_connection_audit_log; device codes are stored there only as
SHA-256 hashes.
Audit rows default to a 30-day retention period and a maximum of 1,000,000
rows. Cleanup runs at startup, every six hours, and after each 1,000 new audit
records, deleting in bounded batches. Super administrators can query the audit
history through /admin/appConnectionAudit/getList. Retention and row limits
can be adjusted with APP_AUDIT_RETENTION_DAYS and APP_AUDIT_MAX_ROWS.
Authenticated Apps can negotiate a data-only WebRTC channel using the
p2p.direct.v1 capability. Manual connections negotiate on this Studio's
/app-relay; cloud connections forward only p2p.offer, p2p.keepalive, and
p2p.close through the cloud. The cloud derives the session owner and device
identity from the formal connection, never from an App-supplied target.
Studio terminates the DataChannel in its backend using werift, including in
CLI/Docker deployments and with the desktop window closed. Each peer has a
separate authenticated loopback /app-relay connection, so the existing local
HTTP and namespace dispatcher enforces device revocation, entitlement expiry,
user/profile permissions, header/path allowlists, and request size limits.
Cloud negotiation additionally checks the local token's device, connection
type, and authorizing Studio user against the formal cloud connection.
The channel uses versioned, ordered JSON frames with binary values encoded as base64. Each frame carries at most 8 KiB of text; complete messages, concurrent requests, reassembly and send buffers are bounded. File downloads use the existing chunk protocol. Download sessions stay pinned to their originating transport. Transport failure rejects pending requests and never automatically replays a write. Namespace bridges own chat resume and resubscription.
This version enables direct ICE candidates only. Studio verifies the selected candidate pair before admitting traffic; TURN offers are rejected. Direct traffic does not pass through the cloud's bandwidth limiter or traffic meter. Fallback traffic continues through the unchanged cloud relay and its normal speed/file policies. Local/manual fallback continues using its existing local policy. File size and memory limits still apply to direct traffic.
The App uses native data-only WebRTC on Android/iOS and browser WebRTC on H5. Missing native modules and failed negotiation preserve the existing relay. Backgrounding closes the direct channel and restores relay namespace bridges, preserving Android's native notification transport and iOS push behavior. The original signaling connection stays open. Studio closes all peers on cloud disconnect; peers must renew a 45-second lease through authorized signaling.
Set STUDIO_P2P_ENABLED=0 to disable direct negotiation. Optional
STUDIO_P2P_STUN_URLS is a comma-separated list of stun: URLs; an empty value
uses host candidates only. Defaults are Cloudflare and Google STUN. TURN is
deliberately unsupported in this version; a later TURN integration must enforce
its bandwidth policy instead of treating a relay candidate as an unlimited
direct connection.
Studio resolves STUN hostnames to usable IPv4 endpoints before creating the
peer. If system DNS returns a synthetic address in 198.18.0.0/15, only the two
built-in public STUN hostnames may use Cloudflare DNS over HTTPS to obtain real
addresses. All resolution shares a 1.5-second deadline; original configured URLs
remain available on failure, and custom/private hostnames never go to this public
resolver. This avoids sending source-bound physical-network UDP sockets to a
TUN-only fake address. It does not override system routing or VPN policies.
Server logs tagged [app-p2p] record an independent attempt ID, STUN resolution
counts, offer/answer candidate counts and connection state. They contain no SDP,
candidate IP addresses or authorization credentials. On cross-network failure,
check whether either side has no srflx candidates and whether ICE reaches
connected; a LAN-only candidate set can work locally while NAT traversal fails.
Studio binds each ICE UDP socket to a real local address instead of a wildcard
address. IPv4 and IPv6 use their respective default-route interfaces. macOS
reads the routing table, Windows reads Get-NetRoute, and Linux reads the
kernel's /proc/net/route and /proc/net/ipv6_route without requiring
iproute2 in the image. If route discovery is unavailable, Studio enumerates
usable local interfaces. Automatic selection excludes known tunnel adapters,
host-side container bridges, loopback, link-local and Clash fake-IP addresses.
The container's own eth0 remains eligible. Selection is refreshed for every
negotiation, including after changing networks.
| Variable | Default | Purpose |
|---|---|---|
STUDIO_P2P_INTERFACE |
automatic | Exact local interface name, such as en0, Ethernet, Wi-Fi, or container eth0. Explicit selection can also use a deployment-owned VPN interface. |
STUDIO_P2P_UDP_PORT_RANGE |
OS-assigned ports; Docker image uses 50000-50127 |
Inclusive UDP range, low-high, between 1024 and 65535, with at most 4096 ports. Use matching firewall and Docker mappings. |
STUDIO_P2P_ADVERTISE_ADDRESSES |
unset | Comma-separated literal host/public IP addresses reachable through the published UDP range. Requires a fixed range and a matching local address family. |
Published addresses add same-port host candidates to the answer; Studio still binds sockets to its own addresses. They do not open extra sockets or bind an address that belongs to the Docker host. Keep Docker host and container UDP port numbers identical. A deployment with multiple containers needs a distinct range per instance. Invalid network settings reject P2P negotiation and the App continues using its existing relay.
Source binding keeps this Mac's P2P traffic out of Clash TUN while retaining the system HTTP proxy. Windows/Linux VPN drivers or policy routing can still force bound sockets through the VPN. Such deployments need a VPN exclusion for Studio's UDP traffic; a STUN-only exclusion is insufficient because ICE checks and DataChannel traffic use peer addresses and the same UDP sockets. With a configured fixed range, filter by the Studio process and that UDP source range, preserving the application's TCP/HTTP proxy. Allow inbound UDP through the OS firewall and, for servers, the cloud security group. Do not disable the whole firewall or alter global proxy settings to enable P2P.
Docker bridge deployments must publish UDP in addition to the HTTP port. For
LAN connectivity, set STUDIO_P2P_ADVERTISE_ADDRESSES to the reachable host LAN
IP; for an explicitly forwarded public range, use the corresponding public IP.
A TCP reverse proxy does not carry this traffic. Linux host networking is an
alternative to bridge publication. Docker Desktop adds a VM/network layer, so
host addresses, UDP publication and the host VPN rules all need to agree.
IPv6 candidates require a usable IPv6 address inside the container as well as
an IPv6 path to the host. NAT or firewall restrictions can still prevent direct
connectivity; the existing authorized relay remains the fallback.
Routing and deployment references: Windows Get-NetRoute, Linux network namespace routing files, Docker port publication.