mirror of
https://github.com/temetro/temetro.git
synced 2026-08-31 04:47:57 +00:00
feat(backend): one-command Dockerized cloudflared tunnel
Add `npm run docker:tunnel` (docker-compose.tunnel.yml overlay): a cloudflared sidecar opens a public quick tunnel to the backend, and the backend auto-discovers its https://…trycloudflare.com URL from cloudflared's metrics endpoint (services/relay-url.ts) and bakes it into the wallet-import QR — so a phone on any network can scan, with no install/account/PUBLIC_RELAY_URL. PUBLIC_RELAY_URL still wins when set. Verified end-to-end: the backend logs the discovered tunnel URL on startup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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://<id>.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
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"dev:tunnel": "node scripts/dev-tunnel.mjs",
|
"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",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const schema = z.object({
|
|||||||
// QR a patient scans. Optional — when unset we derive it from the request host
|
// 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).
|
// (so opening the web app over the LAN yields a reachable LAN URL).
|
||||||
PUBLIC_RELAY_URL: z.string().optional(),
|
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),
|
PORT: z.coerce.number().int().positive().default(4000),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "production", "test"])
|
.enum(["development", "production", "test"])
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { settingsRouter } from "./routes/settings.js";
|
|||||||
import { signingRouter } from "./routes/signing.js";
|
import { signingRouter } from "./routes/signing.js";
|
||||||
import { staffRouter } from "./routes/staff.js";
|
import { staffRouter } from "./routes/staff.js";
|
||||||
import { tasksRouter } from "./routes/tasks.js";
|
import { tasksRouter } from "./routes/tasks.js";
|
||||||
|
import { discoverQuickTunnelUrl } from "./services/relay-url.js";
|
||||||
import { sweepExpiredShares } from "./services/wallet-share.js";
|
import { sweepExpiredShares } from "./services/wallet-share.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -135,3 +136,10 @@ server.listen(env.PORT, () => {
|
|||||||
console.log(` • signing: /api/signing (Ed25519 clinic key)`);
|
console.log(` • signing: /api/signing (Ed25519 clinic key)`);
|
||||||
console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { emitToWallet } from "../realtime.js";
|
import { emitToWallet } from "../realtime.js";
|
||||||
import { recordActivity } from "../services/activity.js";
|
import { recordActivity } from "../services/activity.js";
|
||||||
import * as patientService from "../services/patients.js";
|
import * as patientService from "../services/patients.js";
|
||||||
|
import { getDiscoveredRelayUrl } from "../services/relay-url.js";
|
||||||
import * as walletShare from "../services/wallet-share.js";
|
import * as walletShare from "../services/wallet-share.js";
|
||||||
|
|
||||||
export const patientsWalletRouter = Router();
|
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.
|
// host so that opening the web app over the LAN yields a reachable LAN URL.
|
||||||
function resolveRelayUrl(req: Request): string {
|
function resolveRelayUrl(req: Request): string {
|
||||||
if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL;
|
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");
|
const host = req.get("host");
|
||||||
if (host) {
|
if (host) {
|
||||||
// Behind a TLS-terminating proxy (Fly/Render/etc.) req.protocol is "http";
|
// Behind a TLS-terminating proxy (Fly/Render/etc.) req.protocol is "http";
|
||||||
|
|||||||
@@ -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://<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.
|
||||||
|
|
||||||
|
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://<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;
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user