diff --git a/backend/docker-compose.tunnel.yml b/backend/docker-compose.tunnel.yml new file mode 100644 index 0000000..f3b1ce8 --- /dev/null +++ b/backend/docker-compose.tunnel.yml @@ -0,0 +1,24 @@ +# Overlay that adds a public Cloudflare tunnel so a patient's phone can scan the +# wallet-import QR from ANY network (cellular, other Wi-Fi) — not just the LAN. +# +# npm run docker:tunnel # from backend/ (easiest) +# # equivalently: +# docker compose -f docker-compose.yml -f docker-compose.tunnel.yml up --build +# +# cloudflared opens a random https://.trycloudflare.com URL to the backend +# and exposes it on its metrics endpoint; the backend reads it from there and +# bakes it into the QR (CLOUDFLARED_METRICS_URL below). Zero config — no +# Cloudflare account or token needed for these quick tunnels. + +services: + cloudflared: + image: cloudflare/cloudflared:latest + restart: unless-stopped + depends_on: + - backend + command: tunnel --no-autoupdate --url http://backend:4000 --metrics 0.0.0.0:3333 + + backend: + environment: + # Where the backend reads its public quick-tunnel URL from. + CLOUDFLARED_METRICS_URL: http://cloudflared:3333 diff --git a/backend/package.json b/backend/package.json index 66c8d4d..281ca59 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "dev:tunnel": "node scripts/dev-tunnel.mjs", + "docker:tunnel": "docker compose -f docker-compose.yml -f docker-compose.tunnel.yml up --build", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc --noEmit", diff --git a/backend/src/env.ts b/backend/src/env.ts index bea81a2..e29d6d3 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -25,6 +25,10 @@ const schema = z.object({ // QR a patient scans. Optional — when unset we derive it from the request host // (so opening the web app over the LAN yields a reachable LAN URL). PUBLIC_RELAY_URL: z.string().optional(), + // Metrics URL of a cloudflared quick-tunnel sidecar (e.g. http://cloudflared:3333). + // When set and PUBLIC_RELAY_URL is unset, the backend discovers its public + // trycloudflare.com URL from there for Dockerized off-network testing. + CLOUDFLARED_METRICS_URL: z.string().optional(), PORT: z.coerce.number().int().positive().default(4000), NODE_ENV: z .enum(["development", "production", "test"]) diff --git a/backend/src/index.ts b/backend/src/index.ts index c3ad156..6ace761 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,6 +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 { sweepExpiredShares } from "./services/wallet-share.js"; const app = express(); @@ -135,3 +136,10 @@ server.listen(env.PORT, () => { console.log(` • signing: /api/signing (Ed25519 clinic key)`); console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`); }); + +// Dockerized off-network testing: learn our public Cloudflare quick-tunnel URL +// (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); +} diff --git a/backend/src/routes/patients-wallet.ts b/backend/src/routes/patients-wallet.ts index fe3bba5..2f25053 100644 --- a/backend/src/routes/patients-wallet.ts +++ b/backend/src/routes/patients-wallet.ts @@ -18,6 +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 * as walletShare from "../services/wallet-share.js"; export const patientsWalletRouter = Router(); @@ -29,6 +30,9 @@ patientsWalletRouter.use(requireAuth, requireOrg); // host so that opening the web app over the LAN yields a reachable LAN URL. function resolveRelayUrl(req: Request): 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; const host = req.get("host"); if (host) { // Behind a TLS-terminating proxy (Fly/Render/etc.) req.protocol is "http"; diff --git a/backend/src/services/relay-url.ts b/backend/src/services/relay-url.ts new file mode 100644 index 0000000..638b9a0 --- /dev/null +++ b/backend/src/services/relay-url.ts @@ -0,0 +1,43 @@ +// 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. + +let discovered: string | null = null; + +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; + + while (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; + } + } + } catch { + // cloudflared not up yet (or no tunnel profile) — keep trying until the + // deadline, then quietly stop. + } + await new Promise((r) => setTimeout(r, intervalMs)); + } +}