Skip to content
SorGuayteawLord edited this page Aug 23, 2026 · 2 revisions

Relay mode reaches your harness through dsh-relay — a plugin that runs beside the harness and asks who you are before letting anything through. It is the answer to the problem LAN Mode does not solve: that page serves a coding agent to everyone on your network.

Requires DSH Mobile 0.8.0 or newer and dsh-relay 0.1.1 or newer.

Why it exists

The harness is explicit that it has no authentication layer, and refuses dsh web --host 0.0.0.0 because doing so "would expose remote code execution to the network". LAN mode works around that refusal with a config patch. The relay is the layer itself: the harness keeps its loopback bind, and a second listener in front of it terminates TLS, checks a credential, and forwards.

That ordering matters. A relay that fails to start leaves the harness unreachable from the network — never open to it. And it refuses to start at all if it finds the harness already bound to 0.0.0.0, so you cannot accidentally run both.

What you get

  • A real credential. This device holds a bearer token issued once, at pairing, and sends it on every /api call and both event streams. The relay stores only a keyed hash of it.
  • Encryption, with the key pinned. The app pins the relay's public key rather than trusting a certificate authority, so a self-signed relay is verified rather than merely accepted.
  • Revocation you control. Every paired device is listed at /relay/devices with its own revoke button, and "sign out everywhere" invalidates every token at once.
  • It works from outside your Wi-Fi. Forward the relay's port and it keeps working; the app's "not on this phone's network" check is skipped for an endpoint that holds a token.

What it does not do is reduce what an authenticated client can reach. Pairing a phone gives it the same power as a terminal on that computer, because the agent runs commands there. The question the relay answers is "is the remote user you", not "how much can the remote user do".

Set it up

On the computer running the harness:

dsh plugin --profile web add dsh-relay
dsh web

The terminal prints the relay's addresses. Open one on that machine and set a password — until one exists the sign-in page is loopback-only, so nobody on the network can claim the relay first.

Then open /relay/pair on that machine. It shows a QR code and an eight-digit code; either works, and both expire after a few minutes.

Pair the phone

In the app: Relay → Pair a relay.

  • Scan QR code — point the camera at the code on the pairing page. This is the better route, and not only for convenience: see below.
  • Or type the code — enter the relay's address and the eight digits yourself. Useful when the camera is unavailable or the QR will not focus.

Name the device something you will recognise in the relay's device list, and tap Pair. The app stores the token and connects.

The two routes do not prove the same thing

The app says which one happened, because the difference is real:

What it establishes
Scanned QR The payload carries the relay's public key, so the very first byte the app sends is checked against it.
Typed address Carries no key — the relay only reveals one in its answer — so the certificate is trusted on first contact.

Trust on first contact means an attacker able to answer at that address during pairing would end up holding the enrolment. Over your own Wi-Fi that is a narrow window; over an untrusted network it is not. Scan the QR when you can.

If the relay is running tls: off, the app says so plainly: the token and everything you send travel in the clear.

Behind Funnel, or another HTTPS proxy

Tailscale Funnel — like nginx or Caddy terminating TLS — puts a certificate the phone already trusts in front of the relay, and speaks plain HTTP behind it. That combination changes pairing.

  • Set tls: off on the relay. The proxy encrypts the outside leg already. Left on, pairing stores the relay's own key while every later connection meets the proxy's, and that mismatch is "This relay's key changed" every time.
  • Ignore the QR. The pairing page encodes the address it was opened on. Opened on the machine, that is http://127.0.0.1:… — an address that, on the phone, is the phone.
  • Pair by typing https://<the proxy's hostname> and the eight-digit code. This is the typed route from the table above — trust on first use, and labelled as such. The not-encrypted notice goes once the address starts with https://.

The proxy's hostname must be listed in publicHostnames, or the relay refuses before the code is read — that is Refuses this address, below. On your own Wi-Fi none of this applies: scan the QR, which carries the relay's real key.

Two consequences to expect. The key a typed pairing pins is the one the phone observed — the proxy's — so a proxy that renews its certificate onto a new key produces "This relay's key changed", and you pair again. And the app sends its token as an Authorization: Bearer header, so a proxy that demands its own password in that header (HTTP Basic) cannot sit in front of the relay.

Finding relays

Find relays browses for the relay's mDNS advertisement (_dsh._tcp), which carries the port, whether the listener speaks TLS, and the key to pin — so it answers in a second or two rather than sweeping a subnet.

Nothing depends on it arriving. Plenty of networks filter multicast and the relay's mdns flag can be turned off, so a quiet browse falls back to knocking the two ports a relay actually uses. It also knocks 127.0.0.1 first, which is where a relay reached through adb reverse answers.

A relay that answers but refuses the address you reached it by is listed anyway, with the entry to add to its configuration. That is the most recoverable thing a scan can find — see below.

When something goes wrong

The relay answers HTTP 403 for every unauthorised case and never 401, so the app works out which one it is from what it already knows about the address.

"The relay refused this device." Your token expired, or it was revoked from the relay's device list. Pair again. The app stops retrying rather than backing off forever — there is nothing to wait for.

"Refuses this address" / "…it refuses requests addressed this way." The relay only answers to authorities it knows: loopback, its own addresses, and whatever its operator listed. An emulator's 10.0.2.2, or a hostname it was never told about, gets refused before the pairing code is even read. Add the address to publicHostnames in the relay's cordis.patch.yml and restart it — or reach the relay by an address it already knows. In the harness log this looks like:

[dsh-relay] refused GET /relay/health from 127.0.0.1: untrusted-host

"This relay's key changed." The relay mints a new key whenever the set of addresses its certificate covers changes, so a laptop that moved networks produces this — and so would something else answering at that address. Behind an HTTPS proxy the pinned key is the proxy's, so its certificate renewing produces this too. The app will not guess between them. Pair again only if you were expecting it.

Turn off the compatibility bridge

dsh-relay ships with compat.addressGrants on, which accepts requests from whatever address a paired device was last seen on. It exists for DSH Mobile 0.5.0, which had no field that could carry a token.

A source address is not authentication — it is shared behind NAT, reassigned by DHCP, rotated by IPv6 privacy extensions, and spoofable by anything on the same Wi-Fi. Once every client you use is on 0.8.0 or newer and has paired, set compat.addressGrants: false. Nothing in this app needs it.

See also

  • Security — the trust model for both modes
  • LAN Mode — the unauthenticated alternative, and why to prefer this
  • Connecting — the connect screen itself
  • docs/PROTOCOL.md — the wire contract, for developers

Clone this wiki locally