Files
fusion/scripts/lib/dev-tunnel.mjs
gsxdsm ee57f8a3b9 fix: print the dashboard token with the dev tunnel URL
`pnpm dev --tunnel` published a bare URL under a "public, unauthenticated"
header. That label was wrong for the flag's own default target: --tunnel with
no port aims at the dashboard, which is bearer-token gated, so the recipient
of a shared link got a 401 with no token to supply.

resolveDevTunnelAuth() now classifies the target and the banner says what is
actually true of it:

  token         dashboard with auth on — prints the token and a ?token= link,
                resolved from FUSION_DASHBOARD_TOKEN, FUSION_DAEMON_TOKEN,
                then ~/.fusion/settings.json
  token-pending first run, token not minted yet — defers to the dashboard's
                own startup banner
  no-auth       --no-auth is on; the dashboard really is open
  foreign       a non-dashboard port; Fusion has no auth to lend it, the only
                genuinely ungated case

Auth resolves at banner time rather than flag-parse time so a token the dev
child mints on a first authenticated run is already readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:21:50 -07:00

201 lines
7.2 KiB
JavaScript

/*
FNXC:DevTunnel 2026-08-18-23:40:
`pnpm dev --tunnel` exposes the dev server through a Cloudflare quick tunnel.
The case this exists for: someone working inside a remote Fusion (a container, a shared box) starts a
dev server there and needs to LOOK at it from their own browser. The dev server is bound inside that
machine, so without a tunnel the only options are port publishing or a VPN — both of which need
cooperation from whoever owns the host.
Cloudflare QUICK tunnels are the right tool precisely because a dev server is HTTP: they need no
account, no domain, and no card (the TCP endpoints that SSH would have required need all three).
The trade is that the hostname is random and lives only as long as the process.
NOT a production exposure path: the quick tunnel itself authenticates nobody. When it points at the
dashboard the dashboard's own bearer token is still the gate (see the banner note below); when it
points at any other port there is no gate at all, and the banner says so.
*/
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
/** Cloudflare prints the assigned hostname once the edge accepts the tunnel. */
const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
/** How long to wait for the URL before giving up and leaving the dev server running. */
const DEFAULT_URL_TIMEOUT_MS = 45_000;
export function extractQuickTunnelUrl(text) {
const match = QUICK_TUNNEL_URL.exec(String(text ?? ""));
return match ? match[0] : null;
}
/**
* Start a Cloudflare quick tunnel for a local port.
*
* Resolves once the public URL is known, or with `url: null` if cloudflared never printed one —
* the dev server keeps running either way, since losing the tunnel must not take the dev loop down.
*/
export async function startDevTunnel({
port,
auth,
log = console,
spawnFn = spawn,
timeoutMs = DEFAULT_URL_TIMEOUT_MS,
} = {}) {
if (!port) throw new Error("startDevTunnel requires a port");
const child = spawnFn(
"cloudflared",
["tunnel", "--no-autoupdate", "--url", `http://localhost:${port}`],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let settled = false;
let url = null;
const stop = () => {
if (!child.killed) child.kill("SIGTERM");
};
const urlPromise = new Promise((resolve) => {
const finish = (value) => {
if (settled) return;
settled = true;
url = value;
resolve(value);
};
const scan = (chunk) => {
const found = extractQuickTunnelUrl(chunk);
if (found) finish(found);
};
child.stdout?.on("data", scan);
// cloudflared writes its banner (including the URL) to stderr.
child.stderr?.on("data", scan);
child.on("error", (error) => {
const hint = error?.code === "ENOENT"
? "cloudflared is not installed — install it or drop --tunnel"
: error?.message;
log.error?.(`[fusion:dev] tunnel failed to start: ${hint}`);
finish(null);
});
child.on("exit", (code) => {
if (!settled) {
log.error?.(`[fusion:dev] tunnel exited before publishing a URL (code ${code})`);
finish(null);
}
});
const timer = setTimeout(() => {
if (!settled) {
log.error?.(`[fusion:dev] tunnel did not publish a URL within ${Math.round(timeoutMs / 1000)}s`);
finish(null);
}
}, timeoutMs);
timer.unref?.();
});
await urlPromise;
if (url) {
printDevTunnelBanner({ url, port, auth, log });
}
return { url, stop, child };
}
/*
FNXC:DevTunnel 2026-08-19-01:18:
A tunnel URL alone is not usable when it points at the dashboard: the dashboard is bearer-token
gated, so the recipient lands on an auth wall with no token and the old banner's
"public, unauthenticated" line was actively wrong for the DEFAULT target. The banner now resolves
which of three states the tunnel is actually in and prints the matching thing:
token — dashboard with auth on: print the token AND a token-bearing URL, because handing
someone a URL they cannot open is the whole failure this flag existed to avoid.
The URL embeds the token deliberately (same shape as the local `fn serve` banner);
that is safe for a link you hand to one person and is NOT the `/remote-login`
redirect case, which leaked the daemon token to every recipient of a shared link.
no-auth — `--no-auth` was passed: the tunnel really is open, say so loudly.
foreign — the tunnel points at some other port (a Vite server, say). Fusion has no auth to
lend it, so the unauthenticated warning is correct there and only there.
*/
/** Where the daemon token lives, mirroring core's resolveGlobalDir preference order. */
export function resolveGlobalSettingsFile(home = homedir(), exists = existsSync) {
for (const dir of [join(home, ".fusion"), join(home, ".pi", "fusion"), join(home, ".pi", "kb")]) {
if (exists(dir)) return join(dir, "settings.json");
}
return join(home, ".fusion", "settings.json");
}
function readStoredDaemonToken(settingsFile, read = readFileSync) {
try {
const parsed = JSON.parse(read(settingsFile, "utf8"));
const token = parsed?.daemonToken;
return typeof token === "string" && token.length > 0 ? token : null;
} catch {
return null;
}
}
/**
* Decide what auth (if any) the tunnel's target is behind.
*
* Pure apart from the injected readers so the three states are testable without a real dashboard,
* a real `~/.fusion`, or a real cloudflared.
*/
export function resolveDevTunnelAuth({
port,
dashboardPort,
args = [],
env = process.env,
settingsFile = resolveGlobalSettingsFile(),
readToken = readStoredDaemonToken,
} = {}) {
if (port !== dashboardPort) return { kind: "foreign" };
if (args.includes("--no-auth")) return { kind: "no-auth" };
const token = env.FUSION_DASHBOARD_TOKEN
?? env.FUSION_DAEMON_TOKEN
?? readToken(settingsFile);
// No token on disk yet means the dev child is minting one right now (first authenticated run).
// Predicting it is impossible, so point at the banner that will print it rather than guess.
return token ? { kind: "token", token } : { kind: "token-pending" };
}
export function formatDevTunnelBanner({ url, port, auth }) {
const lines = [` ┌ dev server tunnel`, ` │ ${url} → http://localhost:${port}`];
switch (auth?.kind) {
case "token":
lines.push(` │ token: ${auth.token}`);
lines.push(` │ ready-to-open: ${url}/?token=${encodeURIComponent(auth.token)}`);
lines.push(` └ that link carries the token — share it only with whoever should have access`);
break;
case "token-pending":
lines.push(` └ append the ?token=… from the dashboard's own startup banner to open it`);
break;
case "no-auth":
lines.push(` └ --no-auth is on: anyone with this URL gets your dashboard, unauthenticated`);
break;
default:
lines.push(` └ anyone with this URL can reach that port — Fusion adds no auth to it`);
}
return lines;
}
function printDevTunnelBanner({ url, port, auth, log }) {
log.log?.("");
for (const line of formatDevTunnelBanner({ url, port, auth })) log.log?.(line);
log.log?.("");
}