From a68b7f257335674f159e79750b12822d7806d91c Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 22 Jun 2026 23:56:29 +0300 Subject: [PATCH] fix(backend): make tunnel relay reachable before baking it into QR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes so off-network wallet scanning actually works: - Force the cloudflared edge connection over http2 (TCP/443) instead of QUIC (UDP/7844) in both the Docker tunnel and the dev-tunnel script. QUIC is blocked on many networks/Docker setups, which left the tunnel stuck "Failed to dial a quic connection" and the QR pointing at a dead URL — the root cause of "must be on the same network" failures. - Gate the discovered quick-tunnel URL on real end-to-end reachability: poll the tunnel's own /health until it answers (Cloudflare 1033 clears in ~30s) before publishing it, and have /pair await that discovery so the QR never carries a not-yet-live URL. Co-Authored-By: Claude Opus 4.8 --- backend/docker-compose.tunnel.yml | 5 +- backend/scripts/dev-tunnel.mjs | 4 +- backend/src/index.ts | 4 +- backend/src/routes/patients-wallet.ts | 19 ++++-- backend/src/services/relay-url.ts | 91 ++++++++++++++++++++------- 5 files changed, 89 insertions(+), 34 deletions(-) diff --git a/backend/docker-compose.tunnel.yml b/backend/docker-compose.tunnel.yml index f3b1ce8..fc8826f 100644 --- a/backend/docker-compose.tunnel.yml +++ b/backend/docker-compose.tunnel.yml @@ -16,7 +16,10 @@ services: restart: unless-stopped depends_on: - backend - command: tunnel --no-autoupdate --url http://backend:4000 --metrics 0.0.0.0:3333 + # --protocol http2 forces the edge connection over TCP/443 instead of QUIC + # (UDP/7844), which is blocked on many networks/Docker setups and otherwise + # leaves the tunnel stuck "Failed to dial a quic connection". + command: tunnel --no-autoupdate --protocol http2 --url http://backend:4000 --metrics 0.0.0.0:3333 backend: environment: diff --git a/backend/scripts/dev-tunnel.mjs b/backend/scripts/dev-tunnel.mjs index 00d10b3..ec32e2c 100644 --- a/backend/scripts/dev-tunnel.mjs +++ b/backend/scripts/dev-tunnel.mjs @@ -35,7 +35,9 @@ console.log(`\n⛅ Starting Cloudflare tunnel to http://localhost:${PORT} …\n` const tunnel = spawn( "cloudflared", - ["tunnel", "--url", `http://localhost:${PORT}`], + // --protocol http2 keeps the edge connection on TCP/443 (QUIC/UDP is blocked + // on many networks and would leave the tunnel unable to connect). + ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://localhost:${PORT}`], { stdio: ["ignore", "pipe", "pipe"] }, ); diff --git a/backend/src/index.ts b/backend/src/index.ts index 6ace761..79015a1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,7 +31,7 @@ import { settingsRouter } from "./routes/settings.js"; import { signingRouter } from "./routes/signing.js"; import { staffRouter } from "./routes/staff.js"; import { tasksRouter } from "./routes/tasks.js"; -import { discoverQuickTunnelUrl } from "./services/relay-url.js"; +import { beginQuickTunnelDiscovery } from "./services/relay-url.js"; import { sweepExpiredShares } from "./services/wallet-share.js"; const app = express(); @@ -141,5 +141,5 @@ server.listen(env.PORT, () => { // (from the `tunnel` compose profile) so the wallet-import QR points at it. // No-op unless a tunnel sidecar is configured and PUBLIC_RELAY_URL is unset. if (env.CLOUDFLARED_METRICS_URL && !env.PUBLIC_RELAY_URL) { - void discoverQuickTunnelUrl(env.CLOUDFLARED_METRICS_URL); + void beginQuickTunnelDiscovery(env.CLOUDFLARED_METRICS_URL); } diff --git a/backend/src/routes/patients-wallet.ts b/backend/src/routes/patients-wallet.ts index 2f25053..9c9586c 100644 --- a/backend/src/routes/patients-wallet.ts +++ b/backend/src/routes/patients-wallet.ts @@ -18,7 +18,7 @@ import { import { emitToWallet } from "../realtime.js"; import { recordActivity } from "../services/activity.js"; import * as patientService from "../services/patients.js"; -import { getDiscoveredRelayUrl } from "../services/relay-url.js"; +import { awaitQuickTunnelUrl } from "../services/relay-url.js"; import * as walletShare from "../services/wallet-share.js"; export const patientsWalletRouter = Router(); @@ -28,11 +28,14 @@ patientsWalletRouter.use(requireAuth, requireOrg); // The device-reachable URL the patient's app should connect to (baked into the // QR). Prefer an explicit PUBLIC_RELAY_URL; otherwise derive it from the request // host so that opening the web app over the LAN yields a reachable LAN URL. -function resolveRelayUrl(req: Request): string { +async function resolveRelayUrl(req: Request): Promise { if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL; - // A cloudflared quick tunnel (Docker `--profile tunnel`), if one was found. - const tunnel = getDiscoveredRelayUrl(); - if (tunnel) return tunnel; + // A cloudflared quick tunnel (`npm run docker:tunnel`). Wait briefly for it to + // become reachable so the QR never carries a not-yet-live URL. + if (env.CLOUDFLARED_METRICS_URL) { + const tunnel = await awaitQuickTunnelUrl(env.CLOUDFLARED_METRICS_URL); + if (tunnel) return tunnel; + } const host = req.get("host"); if (host) { // Behind a TLS-terminating proxy (Fly/Render/etc.) req.protocol is "http"; @@ -71,7 +74,11 @@ patientsWalletRouter.post( input.mode, input.durationHours, ); - res.status(201).json({ ...view, ephemeralPubKey, relayUrl: resolveRelayUrl(req) }); + res.status(201).json({ + ...view, + ephemeralPubKey, + relayUrl: await resolveRelayUrl(req), + }); } catch (err) { next(err); } diff --git a/backend/src/services/relay-url.ts b/backend/src/services/relay-url.ts index 638b9a0..9e286f7 100644 --- a/backend/src/services/relay-url.ts +++ b/backend/src/services/relay-url.ts @@ -1,43 +1,86 @@ // Discovers this backend's public relay URL from a cloudflared **quick tunnel** -// so Dockerized off-network testing is zero-config: `docker compose --profile -// tunnel up` starts a cloudflared sidecar that proxies a random -// `https://.trycloudflare.com` URL to the backend, and we read that URL -// from cloudflared's metrics endpoint (`GET /quicktunnel`) and bake it into the -// wallet-import QR. An explicit PUBLIC_RELAY_URL always wins over this. +// so Dockerized off-network testing is zero-config (`npm run docker:tunnel`). +// cloudflared proxies a random `https://.trycloudflare.com` URL to the +// backend and reports it on its metrics endpoint (`GET /quicktunnel`). +// +// Crucially we only publish that URL once it's actually **reachable from the +// public internet** — a fresh quick tunnel returns Cloudflare error 1033 for +// ~30s while it propagates to the edge, and baking a not-yet-reachable URL into +// the QR is exactly what makes a scan fail with "couldn't reach the clinic". +// An explicit PUBLIC_RELAY_URL always wins over this. let discovered: string | null = null; +let discovery: Promise | null = null; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export function getDiscoveredRelayUrl(): string | null { return discovered; } -// Poll cloudflared's metrics `/quicktunnel` endpoint until it reports a -// hostname, then cache `https://`. Best-effort and quiet: if no -// tunnel is running (e.g. plain `docker compose up`), it gives up after the -// window without affecting normal request handling. -export async function discoverQuickTunnelUrl( - metricsUrl: string, - { intervalMs = 2000, timeoutMs = 90_000 }: { intervalMs?: number; timeoutMs?: number } = {}, -): Promise { - const base = metricsUrl.replace(/\/$/, ""); - const deadline = Date.now() + timeoutMs; +// Kick off discovery once (idempotent). Safe to call at startup. +export function beginQuickTunnelDiscovery(metricsUrl: string): Promise { + if (!discovery) discovery = runDiscovery(metricsUrl); + return discovery; +} - while (Date.now() < deadline) { +// Return the discovered public URL, waiting up to `capMs` for an in-flight +// discovery to finish (so a QR generated right after startup still gets the +// tunnel URL rather than a localhost fallback). Null if not ready in time. +export async function awaitQuickTunnelUrl( + metricsUrl: string, + capMs = 25_000, +): Promise { + if (discovered) return discovered; + const inFlight = beginQuickTunnelDiscovery(metricsUrl); + await Promise.race([inFlight, sleep(capMs)]); + return discovered; +} + +async function runDiscovery(metricsUrl: string): Promise { + const base = metricsUrl.replace(/\/$/, ""); + const deadline = Date.now() + 150_000; + + // 1) Ask cloudflared for the quick-tunnel hostname. + let url: string | null = null; + while (!url && Date.now() < deadline) { try { const res = await fetch(`${base}/quicktunnel`); if (res.ok) { const body = (await res.json()) as { hostname?: string }; const host = body.hostname?.trim(); - if (host) { - discovered = host.startsWith("http") ? host : `https://${host}`; - console.log(`Wallet relay reachable via Cloudflare tunnel: ${discovered}`); - return; - } + if (host) url = host.startsWith("http") ? host : `https://${host}`; } } catch { - // cloudflared not up yet (or no tunnel profile) — keep trying until the - // deadline, then quietly stop. + // cloudflared not up yet (or no tunnel running) — keep trying. } - await new Promise((r) => setTimeout(r, intervalMs)); + if (!url) await sleep(2000); } + if (!url) { + console.warn("Cloudflare tunnel: no quick-tunnel URL reported by cloudflared."); + return; + } + + // 2) Wait until the tunnel is actually reachable end-to-end (edge → cloudflared + // → backend) before publishing it, so the QR only ever carries a live URL. + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/health`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + discovered = url; + console.log(`Wallet relay reachable via Cloudflare tunnel: ${url}`); + return; + } + } catch { + // Still propagating (HTTP 1033 / timeout) — retry. + } + await sleep(2000); + } + + // Edge never confirmed within the window; publish best-effort (it usually + // comes up shortly) but warn so the cause is visible. + discovered = url; + console.warn(`Cloudflare tunnel URL set but not confirmed reachable: ${url}`); }