/** * Cloudflare Quick Tunnel Provider (Issue #1937, R2). * * Implements §6.4 of `docs/design/remote-qr-pairing-1937.md`. Everything below * that says "measured" was measured on 2026-08-29 against cloudflared 2025.4.0; * the raw capture is in `dev-reports/issue/1937/u3-quicktunnel-url.md`. * * ## Three properties this file is responsible for * * 1. **Nothing but loopback is ever exposed.** `LOOPBACK_HOST` is a constant and * is never derived from an argument, so neither the upstream nor the metrics * listener can be pointed anywhere else. That matters twice over for * `--metrics`: `cloudflared tunnel --help` says its default address "binds to * all interfaces" under a virtual environment. CommandMate defaults `CM_BIND` * to 127.0.0.1, and opening a hole in the Provider's own diagnostics port * would give that back. * * 2. **`stop()` only signals the process this module started.** A Quick Tunnel * writes no persistent Provider configuration, so `preexisting` is `null` and * `owned.revert` is `null`; the whole teardown is one SIGTERM to `owned.pid`. * Measured: the process is gone in about a second and the public URL then * answers HTTP 530. There is no reason to reach for any command that acts on * "every tunnel this machine knows about", and * `tests/unit/config/remote-destructive-command-guard.test.ts` forbids it. * This is not hypothetical — the machine this was measured on had a named * tunnel of the user's own running for nine days at the time. * * 3. **The URL comes from the metrics API first.** Measured order: the stderr * banner appears at t+3s, the metrics server only at t+4s. So a loop that * tried both every round would find the banner first every single time and * `/quicktunnel` would be dead code. `METRICS_PREFERENCE_WINDOW_MS` is what * keeps the first candidate actually first: for that long, only the metrics * route is consulted. * * 4. **The tunnel outlives the CLI that started it.** `commandmate remote up` * exits as soon as it has a URL — measured at about 6.5 seconds, taken from * the metrics API. cloudflared has to still be there afterwards, because the * whole point is a QR code someone reads with a phone a minute later. Two * things make that true, and Issue #2146 is what happens without them: * cloudflared's stderr goes to a **file descriptor, never a pipe**, and the * child is spawned `detached` and `unref`ed. * * Measured (Issue #2146, `docs/qa/1937-remote-uat-record.md` D-1): with * `stdio: ['ignore', 'ignore', 'pipe']` the child was dead within two * seconds of the parent exiting, and the public URL answered HTTP 530 before * the QR code could be read. The parent's exit closes the read end of that * pipe; cloudflared is Go, does not ignore SIGPIPE, and dies on its next * write to fd 2 — which, at t+6.5s, is still mid log-burst. The same argv * with a parent kept alive for 70 seconds gave a child that lived 70 * seconds, so this is the plumbing and not cloudflared giving up. * * `'ignore'` would also have fixed it and is the wrong fix: it takes fd 2 * away entirely, and fd 2 is where both the **second URL candidate** * (`parseBannerUrl`) and every failure diagnostic (`stderrTail`) come from. * A file keeps both — nothing closes when the CLI exits, and the parser and * the diagnostic read it back by path. `--logfile` was the other candidate: * it covers cloudflared's own log lines but not anything the Go runtime or * the loader writes straight to fd 2, so fd 2 would still have been a pipe. * `'ignore'` for fd 2 is what this falls back to only when the log file * cannot be opened at all: a diagnostic is worth less than a working tunnel. * * Deliberately absent: the approval prompt. Creating a public URL is an * irreversible, user-facing decision, and §6.2 puts it in the orchestrator that * owns `src/cli/commands/remote.ts` — the same code that knows whether the * session is interactive and whether `--yes` was passed. * * This module imports Node builtins and `./types` only. No `@/` alias, so the * orchestrator can pull it into the `tsconfig.cli.json` build (which sets * `paths: {}`) without the import failing to resolve. */ import { type SpawnOptions, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from 'child_process'; import { type RemoteProvider } from './types'; /** The executable. Looked up on PATH by `spawn`, never through a shell. */ export declare const CLOUDFLARED_BIN = "cloudflared"; /** Mirrors `PreflightChecker.checkDependency()`: one flag, passed as an array. */ export declare const CLOUDFLARED_VERSION_ARG = "--version"; /** * The only host this Provider ever names. * * A constant rather than a parameter on purpose: "the upstream is always * loopback" is then a property of the code, not of every call site. §9.2 pins it * from the outside as well, by asserting the argv `start()` builds. */ export declare const LOOPBACK_HOST = "127.0.0.1"; /** Quick Tunnel hostnames live under this suffix, and nothing else does. */ export declare const QUICK_TUNNEL_SUFFIX = "trycloudflare.com"; /** Written next to the rest of CommandMate's state, for humans and for `ps`. */ export declare const CLOUDFLARED_PIDFILE_NAME = "cloudflared.pid"; /** * Where cloudflared's stderr goes. Beside the pidfile, and for the same reason: * a human who wants to know what the tunnel is doing should be able to find it. * * A fixed name, like the pidfile, so one `remote` session at a time is the * assumption in both places rather than in one of them. */ export declare const CLOUDFLARED_LOG_NAME = "cloudflared.log"; /** Same 5s budget `PreflightChecker` gives every other dependency probe. */ export declare const DETECT_TIMEOUT_MS = 5000; /** How the wait for a public URL is paced. See `QuickTunnelTiming`. */ export interface QuickTunnelTiming { /** Total budget for the URL to appear before `start()` gives up. */ urlWaitMs: number; /** * How long only `/quicktunnel` is consulted. * * Measured: the banner lands ~1s *before* the metrics server is listening. A * window shorter than that gap turns the documented first candidate into * something that never runs. */ metricsPreferenceMs: number; /** Gap between polls. */ pollIntervalMs: number; } export declare const DEFAULT_QUICK_TUNNEL_TIMING: QuickTunnelTiming; /** Per-request budget for the loopback metrics call. */ export declare const METRICS_REQUEST_TIMEOUT_MS = 1000; /** * How much of the stderr log is read back, so a chatty reconnect loop cannot * make either consumer grow unbounded. * * The **tail**, not the head: `parseBannerUrl` takes the last match on purpose * (a reconnect banner should win), and `stderrTail` wants the last few lines. */ export declare const STDERR_CAPTURE_LIMIT: number; /** * The part of a spawned cloudflared this module uses, and nothing more. * * No `stderr`: as of Issue #2146 fd 2 is a file, so there is no stream to read * and nothing here that could be tempted to hold one open. `unref` is required * rather than optional because forgetting it is exactly the class of mistake * this interface exists to make impossible. */ export interface QuickTunnelProcess { readonly pid?: number | undefined; once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown; kill(signal?: NodeJS.Signals): boolean; /** Releases the parent's event-loop reference to this child. */ unref(): unknown; } export type SpawnQuickTunnel = (command: string, args: readonly string[], options: SpawnOptions) => QuickTunnelProcess; export type SpawnSyncProbe = (command: string, args: readonly string[], options: SpawnSyncOptionsWithStringEncoding) => SpawnSyncReturns; /** * The seams that let this Provider be tested without a public tunnel. * * They are constructor arguments rather than members of the Provider because * `RemoteProvider` is pinned to exactly `id` / `detect` / `start` / `stop` * (§6.3-1): the returned object must not grow a fifth key. */ export interface CloudflareProviderDeps { spawn: SpawnQuickTunnel; spawnSync: SpawnSyncProbe; /** Signals a process. Defaults to `process.kill`. */ kill: (pid: number, signal: NodeJS.Signals) => void; /** Picks the `--metrics` port. Bound on loopback, then released. */ findFreePort: () => Promise; /** The first URL candidate: `GET /quicktunnel` on the metrics listener. */ fetchHostname: (metricsPort: number) => Promise; /** Directory the pidfile goes in. */ resolveStateDir: () => string; timing: QuickTunnelTiming; } /** * The argv for one Quick Tunnel. * * `LOOPBACK_HOST` appears twice and is a constant both times. `port` is the only * caller-supplied value that reaches the command line, and it is validated as a * port number before it gets here. */ export declare function buildQuickTunnelArgs(opts: { port: number; metricsPort: number; pidfile: string; }): string[]; /** * The spawn options for one Quick Tunnel. Issue #2146 is entirely about these. * * `stderr` is a **file descriptor**, or `'ignore'` when the log file could not * be opened. It is never `'pipe'`: a pipe's read end belongs to `commandmate * remote`, which exits as soon as it has the URL, and cloudflared then dies of * SIGPIPE on its next write to fd 2. See the file header for the measurement. * * `detached: true` puts the child in its own session. Without it, a Ctrl-C in * the terminal that launched the CLI reaches cloudflared too — the tunnel is * supposed to outlive the command, so it must not share its process group. * `stop()` is unaffected: it signals a **positive** pid, which is one process, * not a group, so detaching changes nothing about teardown. */ export declare function buildQuickTunnelSpawnOptions(stderr: number | 'ignore'): SpawnOptions; /** * cloudflared's stderr, as this module hands it out and reads it back. * * Two consumers depend on it and both survive the change from a pipe to a file, * because a file can be re-read by path at any time: `parseBannerUrl()` (the * second URL candidate) and `stderrTail()` (the failure diagnostic). */ export interface StderrLog { /** Goes in slot 2 of `stdio`. A descriptor, or `'ignore'`. Never `'pipe'`. */ readonly stdio: number | 'ignore'; /** The file being written to, or `null` when there is none. */ readonly path: string | null; /** The tail of what cloudflared has written so far. Never throws. */ read(): string; /** Closes this process's copy of the descriptor. The child keeps its own. */ close(): void; } /** * Opens the file cloudflared's stderr is redirected into. * * Truncating (`'w'`) rather than appending, because `parseBannerUrl()` takes the * last URL in the buffer and a previous session's banner names a tunnel that no * longer exists. Starting from empty means the only banner that can be read is * this session's. * * Returns `SILENT_STDERR_LOG` rather than throwing when the file cannot be * opened: `start()` still has `/quicktunnel` — the *first* URL candidate — and * a tunnel with no diagnostics beats no tunnel. */ export declare function openStderrLog(logPath: string): StderrLog; /** True for a hostname cloudflared could actually have handed out. */ export declare function isQuickTunnelHostname(value: string): boolean; /** * Reads the hostname out of a `/quicktunnel` response body. * * Measured body: `{"hostname":"villas-activists-hey-barbie.trycloudflare.com"}` * — served as `text/plain`, and **without a scheme**, so the `https://` is ours * to add. The shape is checked before it is used because this string becomes the * QR code a phone is asked to open; a value that is not a Quick Tunnel hostname * is treated as no answer rather than as a destination. */ export declare function parseQuickTunnelHostname(body: string): string | null; /** * Second candidate: the URL cloudflared prints on stderr. * * Anchored on the **shape of the URL**, not on the surrounding words. Two * measured facts drive that. The banner text and the URL are on *different* * lines — the URL sits alone inside a box-drawn frame — so "read what follows * `Visit it at`" does not work against the real output. And the wording is * exactly the part Cloudflare is free to reword, while the hostname suffix is * the service's identity. * * The same stderr also carries `https://www.cloudflare.com/website-terms/` and * `https://developers.cloudflare.com/...`; neither ends in the Quick Tunnel * suffix, and the `Requesting new quick Tunnel on trycloudflare.com...` line has * no scheme. All three are negative controls in the unit test. * * The last match wins, so a reconnect that prints a fresher banner is preferred. */ export declare function parseBannerUrl(stderr: string): string | null; /** * Asks the OS for a free port on loopback and hands it back unheld. * * Binding the probe to `LOOPBACK_HOST` rather than to every interface is part of * the same guarantee as the `--metrics` argument: the port this returns is one * that is free *on loopback*, which is the only place it will be used. */ export declare function findFreeLoopbackPort(): Promise; /** * `GET http://127.0.0.1:/quicktunnel`. * * Never rejects: "no answer yet" and "no such route" are both normal during the * poll, and neither should look different from a slow start. */ export declare function fetchQuickTunnelHostname(metricsPort: number, timeoutMs?: number): Promise; /** * Where the pidfile goes: the same `~/.commandmate` the rest of CommandMate * uses. * * Not read from the environment. §6.2 keeps state-file locations out of * Providers, and an env-supplied directory would also have to defend against the * `/proc` recursive-mkdir hang that `resolveSafeDirectory()` exists for. Tests * override it through `CloudflareProviderDeps` instead. */ export declare function resolveCloudflareStateDir(): string; export declare const defaultCloudflareDeps: CloudflareProviderDeps; /** * Builds the Provider. * * The returned object has exactly the four members `RemoteProvider` declares — * the seams stay closed over in here rather than becoming a fifth key, because * the contract test pins that list and §6.3-1 is the reason it does. */ export declare function createCloudflareProvider(overrides?: Partial): RemoteProvider; /** The shipped Provider, wired to the real `cloudflared`. */ export declare const cloudflareProvider: RemoteProvider; //# sourceMappingURL=cloudflare.d.ts.map