mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
fix(backend): make tunnel relay reachable before baking it into QR
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"] },
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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://<sub>.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://<sub>.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<void> | 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://<hostname>`. 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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
if (discovered) return discovered;
|
||||
const inFlight = beginQuickTunnelDiscovery(metricsUrl);
|
||||
await Promise.race([inFlight, sleep(capMs)]);
|
||||
return discovered;
|
||||
}
|
||||
|
||||
async function runDiscovery(metricsUrl: string): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user