diff --git a/docs/design/remote-access-design.md b/docs/design/remote-access-design.md
index e42c1117..ec5dda8f 100644
--- a/docs/design/remote-access-design.md
+++ b/docs/design/remote-access-design.md
@@ -850,13 +850,31 @@ consumer behind whatever a cloud provider gives a container, srflx-to-srflx need
allow a hole to be punched — often they do, and often enough they do not. A relay always works, at
the cost of somebody's bandwidth, which is why ICE tries it last.
-**Only the robot offers one**, and that is the part worth knowing before writing any of it: a
-connection needs *one* relay candidate, not two. If the robot offers one, a consumer that can
-reach the internet uses it — so credentials live on the robot and a consumer needs none. Which
-matters more than it sounds, because `aiortc`'s STUN client works where its TURN client does not,
-so a Python consumer *cannot* be the side that relays. `reachy_mini`'s #1182 established this
+**One relay candidate is enough, and it is not always the robot's.** A connection needs *one*, not
+two, and `aiortc`'s STUN client works where its TURN client does not — so a Python consumer cannot
+be the side that relays, and the robot has to be. `reachy_mini`'s #1182 established that
arrangement and `mediad::turn` is the same one.
+**What that argument left out is whether the two ends can address each other at all**, and an
+iPhone on a mobile network is the case where they cannot. It has no IPv4 socket: it reaches a
+*hostname* through DNS64/NAT64, the STUN server reports an IPv4 reflexive address back, and the
+phone gathers a candidate saying so. But an ICE candidate is a bare literal, and the robot's relay
+candidate is a bare IPv4 literal on a board with no global IPv6 at all — which that phone cannot
+send a packet to. Measured on olducky: six sessions, `offering relay candidates relays=5` every
+time, `Ice connection state … failed` every time, about eight seconds apart.
+
+So **the console offers a relay of its own** (`refreshRelays` in `mediad/webclient/index.html`),
+and only its own allocation can bridge this: `turn.cloudflare.com` is a name, so it resolves over
+IPv6, and the relayed address Cloudflare hands back is IPv4, which the robot can reach. Confirmed
+from the phone before it was written — the same credentials in a Trickle ICE page gathered a
+`relay` candidate with an IPv4 address over 4G, where the robot's own candidates paired with
+nothing.
+
+The page mints them with **the visitor's** token, not the robot's, which is the right way round
+twice over: a robot's allowance should go on being watched rather than on watching, and a browser
+signed in with `hf_oauth` already holds a token of its own. A LAN session asks for none — there
+are host candidates on both sides and nothing would use a relay.
+
The credentials are Cloudflare's, minted per account by a proxy Hugging Face hosts and
authenticated with **the same token the relay signs in with** — so a robot that belongs to
somebody can offer a relay and one that belongs to nobody cannot, which is the same line §2 draws
diff --git a/mediad/webclient/index.html b/mediad/webclient/index.html
index fc1f4f8c..b9d43e21 100644
--- a/mediad/webclient/index.html
+++ b/mediad/webclient/index.html
@@ -317,6 +317,13 @@
telemetry
? PARAMS.get("mode") === "remote"
: !SERVED_BY_ROBOT && location.protocol === "https:";
+// Where this page mints its own relay credentials, addressed as the Space it is rather than
+// through `turn.fastrtc.org` — that alias is a dangling delegation and answers nothing
+// (`remote-access-design.md` §6). `?turn=` points this at a local copy or a fake.
+const TURN_CREDENTIALS_URL = PARAMS.get("turn")
+ || "https://fastrtc-turn-service.hf.space/credentials";
+const TURN_TTL_SECONDS = 600;
+
// The Space the robot's relay registers with. `?rendezvous=` points this at a local copy.
const RENDEZVOUS = (PARAMS.get("rendezvous")
|| "https://pollen-robotics-reachy-mini-central.hf.space").replace(/\/$/, "");
@@ -429,6 +436,9 @@ telemetry
// The rendezvous half: the account token, a handle on the open event stream, and which robot of
// the account's this page is driving.
let token = null, stream = null, chosen = null;
+// The relay servers this page holds for itself, in the `{urls, username, credential}` shape
+// `RTCPeerConnection` takes. Empty until `refreshRelays` has run, and empty forever on a LAN.
+let relays = [];
// Whether a session is wanted. Set by a person — the connect button, or picking another robot —
// and cleared when the robot ends one.
//
@@ -711,6 +721,10 @@ telemetry
$("account").hidden = false;
}
setState("connecting…");
+ // Before the session rather than during it: `newPeerConnection` runs in a message handler
+ // that cannot wait, and credentials are short-lived enough that minting them per connect
+ // is simpler than keeping a fresh set around.
+ await refreshRelays();
log("dim", "opening the rendezvous event stream…");
let spoke = false;
stream = await openRendezvous((data) => {
@@ -901,6 +915,46 @@ telemetry
}
}
+/// Mint relay credentials for **this visitor**, so the page can offer a relay of its own.
+///
+/// §6 of `remote-access-design.md` argued only the robot needs to offer one: a connection needs
+/// one relay candidate and not two, and `aiortc`'s TURN client does not work, so a Python
+/// consumer could never be the side that relays. Both halves are still true, and the conclusion
+/// was still wrong for a browser — because it assumed the two ends can address each other at all.
+///
+/// **An iPhone on a mobile network has no IPv4 socket.** It reaches a *hostname* through
+/// DNS64/NAT64 and the STUN server reports an IPv4 reflexive address back, so it looks like it
+/// has IPv4 and gathers a candidate saying so. But an ICE candidate is a bare literal, and the
+/// robot's relay candidate is a bare IPv4 literal, which that phone cannot send a packet to.
+/// Six sessions negotiated perfectly and carried nothing, which is the same symptom a missing
+/// relay gives and a different cause.
+///
+/// Its own allocation is what bridges the two, and only its own can: `turn.cloudflare.com` is a
+/// name, so it resolves over IPv6, and the relayed address Cloudflare hands back is IPv4 — which
+/// the robot can reach. One relay candidate is still enough. It just has to be this end's.
+///
+/// Metered against the visitor's account rather than the robot owner's, which is the right way
+/// round: a robot's allowance should go on being watched, not on watching.
+async function refreshRelays() {
+ relays = [];
+ if (!REMOTE || !token) return;
+ try {
+ const response = await fetch(`${TURN_CREDENTIALS_URL}?ttl=${TURN_TTL_SECONDS}`, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const offered = (await response.json()).iceServers || [];
+ // A `stun:` entry has no credentials and is not a relay; the STUN server is set below.
+ relays = offered.filter((server) => server.username && server.credential);
+ log("dim", `relay credentials: ${relays.length} server(s)`);
+ } catch (e) {
+ // Best effort, always. Without a relay this page still reaches every robot it could reach
+ // before, which is most of them — so this is a line in the log and not a failed connect.
+ log("bad", "!! no relay credentials:", e.message || String(e),
+ "— a session from a mobile network may negotiate and carry nothing");
+ }
+}
+
function newPeerConnection() {
// **STUN only when it is needed, which is when this page is not on the robot's network.**
//
@@ -912,11 +966,15 @@ telemetry
// nothing.
//
// `stun.l.google.com` is what `webrtcsink` already defaults to on the robot side, so the two
- // ends use one server rather than two. TURN is the next thing this needs — see
- // `remote-access-design.md` §6, and `reachy_mini`'s #1182, where the *robot* fetches
- // short-lived Cloudflare credentials with its own HF token.
+ // ends use one server rather than two.
+ //
+ // **And this page's own relay servers, when `refreshRelays` got any.** Reading them here rather
+ // than fetching is the same rule the robot follows in its `consumer-added` handler: this runs
+ // inside a signalling message handler, and an HTTP request in it would delay the answer to
+ // every session, including the LAN ones that will never use a relay. `relays` is filled on the
+ // connect path, which is already asynchronous and already says "connecting…".
pc = new RTCPeerConnection(REMOTE
- ? { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }
+ ? { iceServers: [{ urls: "stun:stun.l.google.com:19302" }, ...relays] }
: undefined);
pc.ontrack = (event) => {