Skip to content

feat: optional adb relay of the device hosts in a private network - #277

Open
azarouski wants to merge 5 commits into
developfrom
feat/adb-relay
Open

azarouski wants to merge 5 commits into
developfrom
feat/adb-relay

Conversation

@azarouski

@azarouski azarouski commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

When the device hosts (mcloud-agent) are in a private LAN and only the MCloud server is public, the screens, the tests, ZeroMQ and the storage already go through the server. The remote debug of STF (adb connect <host>:<port>) does not: the port is a device port on the device host, and the users cannot reach it. adb-relay relays the remote debug ports through the server. In the recommended auto mode it needs no manual list.

How

Auto mode (recommended): ADB_RELAY=true

  • Every ADB_RELAY_SYNC_SECONDS (default 300), adb-relay reads GET /api/v1/devices?fields=serial,platform,display.url,screenPort,connectPort with STF_TOKEN of variables.env, the token the grid uses.
  • The relay runs on the host network, so it does not get variables.env and its other secrets. ./zebrunner.sh setup, start and restart copy only STF_TOKEN into configuration/adb-relay/stf-token:
    • written in place, so the bind mount of the relay sees updates;
    • created with umask 077 and kept at mode 600 (protect_files covers it too, and gives it back to the folder owner after sudo);
    • empty when the auto mode is off, ignored by git, removed by shutdown.
  • The relay re-reads the file on every sync, so a new token needs only ./zebrunner.sh start or restart, not a recreate. It logs when the STF API answers again after an error.
  • It relays the remote debug port of every Android device registered in STF whose device host (the /d/<host>/ part of display.url) is in AGENT_HOSTS, to the same port of that host.
    • This includes absent devices: the device host refuses the connection, and the relay answers it as a 502 in its log. present is no longer requested from the API.
    • So the generated servers change only when a device is added to STF, removed or given another port, and nginx reloads almost never.
    • The port is connectPort when STF has it (the parallel STF change, needed for connect_port of mcloud-agent).
    • Otherwise the port is the screen port + 1, because STF allocates the screen port, then the connect port. The screen port comes from screenPort or the last segment of display.url.
    • Screen ports are never relayed.
  • ADB_RELAY_PORTS=<first>-<last> (optional) limits the relay to the range opened in the firewall.
  • Skipped and logged: devices of hosts not in AGENT_HOSTS, ports outside ADB_RELAY_PORTS, ports of MCloud or of ADB_RELAY_HOSTS, and a port of two devices (both are skipped; usually a stale registration plus a new device with the same port). The log names both serials and the fix: remove the gone device in STF (Settings, Devices) or change connect_port in mcloud-agent. iOS devices are ignored.
  • nginx is reloaded only when the generated servers change: nginx -t first, then SIGHUP, which is graceful, so open adb sessions go on (checked in the docker test). If nginx -t fails, the previous servers are kept.
  • If the API fails (no token, 401, connection refused, an answer without devices), the relay keeps its devices and logs the error once; it logs again when the API recovers.
  • The log lists the relayed devices and the skipped ones with the reason whenever they change.
  • The relay starts with an empty server list and needs no devices to start.

Why polling

  • STF is the single source of truth: mcloud-agent already registers every device with its ports in STF, and the relay only reads them.
  • No write channel or secret is needed from the agents to the relay, and the relay heals itself after a restart of the relay, STF or a device host.
  • A new device becomes relayable within ADB_RELAY_SYNC_SECONDS (300 s), or at once after ./zebrunner.sh restart or docker restart adb-relay (the relay syncs when it starts).

How it reaches the STF API

  • The relay is in the host network, so it uses stf-proxy on 127.0.0.1:${STF_PORT} with the scheme of STF_URL. That scheme matches the stf-proxy listener in a normal setup and in Zebrunner CE.
  • curl -k skips the certificate check: the certificate is issued for the public name, not for the loopback address.
  • A host-network container reaching a published port on 127.0.0.1 works on Linux and Docker Desktop (checked).

Implementation choice

  • No new image. The relay is still nginx:${TAG_NGINX} with entrypoint: ["bash", "/opt/adb-relay/adb-relay.sh"].
    • In auto mode the script starts nginx (the official entrypoint, foreground) as a child and runs the sync loop. TERM stops nginx; if nginx exits, the container exits and is restarted.
    • Without auto mode it execs the official entrypoint as before.
  • The image has curl, bash, mawk and the njs CLI, but no jq, python or perl JSON module.
  • The JSON is read by a small awk reader (configuration/adb-relay/devices.awk); the relay decisions are in relay.awk. I chose awk over njs for these reasons:
    • The same code runs in make test on the CI and developer machines without docker (macOS bash 3.2 with BSD awk, Linux with mawk).
    • The docker test also runs the unit tests inside the image (bash 5, mawk).
    • fields= keeps the answer small, and the reader handles escapes, nesting, null values and pretty-printed or one-line JSON.
    • Host names go into the nginx config only after an exact AGENT_HOSTS match plus a character-class check.
  • Generated servers: one per device, listen <port> so_keepalive=on; proxy_pass <host>:$server_port;, with a per-server resolver (from /etc/resolv.conf) for hosts given by name.

ADB_RELAY_HOSTS: a fixed list, as before

  • Without ADB_RELAY, or in addition to it: <host>:<port> entries (a range is accepted, with the warning that it also exposes the screens).
  • zebrunner.sh writes relay-hosts.conf from it; these ports win over auto-mode devices with the same port.

zebrunner.sh

  • Starts the adb-relay profile when ADB_RELAY=true or ADB_RELAY_HOSTS has a valid entry. stop, down and shutdown always pass the profile.
  • Validates ADB_RELAY (true/false), ADB_RELAY_PORTS and ADB_RELAY_SYNC_SECONDS.
  • In auto mode, warns about an empty STF_TOKEN or an empty AGENT_HOSTS.
  • Writes configuration/adb-relay/stf-token on setup, start and restart.
  • setup keeps the ADB_RELAY* settings of the previous setup.

README

  • Auto mode as the recommended setup: ADB_RELAY=true, ADB_RELAY_PORTS for the firewall, REMOTE_CONNECT_HOST of mcloud-agent, connect_port of mcloud-agent optional.
  • The manual list as the alternative, the .env rows, Firewall and Services rows, the security note, and troubleshooting (the relay log lists the relayed and skipped devices, plus the meaning of API errors).

Configuration

  1. On the server, set in .env: ADB_RELAY=true, ADB_RELAY_PORTS=7400-7999 (the range opened in the firewall), and AGENT_HOSTS. STF_TOKEN in variables.env must be set (the grid needs it too). Then run ./zebrunner.sh restart.
  2. On the agent: set REMOTE_CONNECT_HOST to the public name of MCloud. mcloud-agent passes it to mcloud-device, so STF shows adb connect <mcloud>:<port>. Optionally set connect_port per device; it needs the STF with connectPort.
  3. Open ADB_RELAY_PORTS in the server firewall for the users.

This PR pairs with mcloud-device REMOTE_CONNECT_HOST, the mcloud-agent settings (REMOTE_CONNECT_HOST, connect_port) and zebrunner/stf feat/fixed-connect-port.

Security

  • The relay has no authentication of its own.
  • The remote debug port (checked in zebrunner/stf lib/units/device/plugins/connect.ts and the processor) listens only while a user is using the device. It accepts only that user's adb keys (STF Settings, ADB keys), and STF asks that user about an unknown key.
  • The screen WebSocket (min_port) has no authentication of its own. The auto mode never relays it. A manual range would make it public, so the range triggers a warning and the README says to put a range only behind a firewall that allows the users' networks.

Tests

  • make lint: ok. The new scripts and awk files are linted, and docker compose config runs with and without the profile.
  • make test (macOS, bash 3.2, BSD awk): ok, 362 checks.
    • New tests/adb_relay_test.sh:
      • device JSON of an older STF (no screenPort/connectPort, pretty-printed, escapes) and of a newer one (with them, display: null, iOS);
      • empty, non-JSON and cut answers;
      • screen port + 1 versus connectPort, and the screen port is never relayed;
      • iOS devices are ignored, absent devices are relayed;
      • hosts outside AGENT_HOSTS (whole-name match only), ports outside ADB_RELAY_PORTS, MCloud and manual ports (single and ranges), and the same port of two devices are skipped for both (including a stale registration plus a new device), with both serials and the fix in the reason;
      • nginx servers with resolvers;
      • the token file (first line, spaces and CR stripped, a missing file);
    • the sync with curl and nginx stubs: a first sync reloads, no change means no reload and no log, a change reloads and logs, and an API failure, an answer without devices, a refused config and an empty token each keep the servers and log once; the end of an error is logged.
    • tests/zebrunner_test.sh (auto mode section):
      • auto mode starts the profile without manual entries; ADB_RELAY=yes and false do not;
      • setup keeps the ADB_RELAY* settings;
      • the default ADB_RELAY_SYNC_SECONDS is 300;
      • invalid ADB_RELAY_PORTS and ADB_RELAY_SYNC_SECONDS warn, as do an empty STF_TOKEN or AGENT_HOSTS;
      • docker compose config passes the settings to adb-relay;
      • stf-token holds only the token (quotes stripped), mode 600, updated in place on start (same inode, mode restored), recreated by restart, empty with ADB_RELAY=false, given back to the folder owner after sudo, removed by shutdown.
  • New docker test tests/adb_relay_docker_test.sh (in DOCKER_TESTS): ok, 33 checks in about 40 s on Docker Desktop (the test sets ADB_RELAY_SYNC_SECONDS=2). All the containers and networks it creates are removed.
    • Setup: a fake STF API published like stf-proxy, a stand-in device host in a docker network, and a manual ADB_RELAY_HOSTS entry.
    • Relayed: an older-STF device (screen + 1), a newer-STF device (connectPort), the manual port, and an absent device. The device host refuses the absent device's port, which the relay logs as 502.
    • Skipped: the manual-port conflict, a host outside AGENT_HOSTS and a port outside ADB_RELAY_PORTS.
    • The screen port is refused, and the relay is healthy.
    • When the devices change, the relay reloads: a new device works, a device removed from STF is refused, and an adb-like session that was open across the reload goes on.
    • The relay reads stf-token. A refused token is logged and the devices are kept; the correct token, written in place, is picked up on the next sync without a restart. variables.env is not mounted into the relay.
    • The manual-only mode (ADB_RELAY=false) works without a sync.
    • The unit tests pass inside the image (bash 5, mawk).
  • make docker: tests/adb_relay_docker_test.sh passes. tests/compose_test.sh stops at the pull because the pinned TAG_STF=3.1 and TAG_SELENIUM_HUB=3.1 are not published on public.ecr.aws; this is not caused by this PR. With published beta images, its relay section passed on the first commit.

azarouski and others added 2 commits November 9, 2026 15:19
ADB_RELAY_HOSTS of .env (<host>:<first>-<last>, comma separated) starts
adb-relay, the nginx of stf-proxy in the network of the host, which relays
the port ranges of the devices of the device hosts for adb connect of STF.
zebrunner.sh writes configuration/adb-relay/relay-hosts.conf from it, skips
invalid entries, overlapping ranges and ranges with a port of MCloud, and
runs compose with the profile adb-relay; stop, down and shutdown always
include the profile. The e2e device drops proxy_port and server_proxy_port.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ADB_RELAY_HOSTS takes <host>:<port> entries, a device host in several of
them: the remote debug port of every Android device (min_port + 1) is the
documented default. A range is still relayed, with a warning that it also
makes the screens of the devices public (min_port, no sign-in of their own).
A port of an earlier entry is skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
azarouski and others added 3 commits November 9, 2026 16:15
ADB_RELAY=true makes adb-relay read the Android devices of STF every
ADB_RELAY_SYNC_SECONDS (STF_TOKEN of variables.env, stf-proxy on the port
of the host) and relay the remote debug port of every present device of
AGENT_HOSTS (connectPort, the screen port + 1 of an older STF), never the
screen ports, in ADB_RELAY_PORTS only; a port of two devices, of MCloud or
of ADB_RELAY_HOSTS is skipped and logged. nginx reloads gracefully only
when the servers change. The sync is configuration/adb-relay/adb-relay.sh
with awk programs in the nginx image (no new image, no jq there).
zebrunner.sh starts adb-relay also in the auto mode and checks its
settings; ADB_RELAY_HOSTS stays a fixed list in addition.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
adb-relay runs on the network of the host: instead of all the secrets of
variables.env it gets configuration/adb-relay/stf-token, which
./zebrunner.sh setup, start and restart write from STF_TOKEN in place
(mode 600, empty without the auto mode, ignored by git, removed by
shutdown). The relay reads it on every sync and logs when the STF API
answers again after an error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The auto mode relays the remote debug port of every Android device of STF
of AGENT_HOSTS, present or not (the device host refuses the port of an
absent one): the servers of nginx change only when a device is added,
removed or moved, so nginx reloads almost never. present is no longer
read from the API. A port of two devices (e.g. a stale registration and a
new device) is skipped for both and logged with their serials and the fix.
ADB_RELAY_SYNC_SECONDS defaults to 300; README explains why the relay
polls STF. Also fixes a broken sentence of the README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant