diff --git a/README.md b/README.md index 4500a337f1..2d4cf97db4 100644 --- a/README.md +++ b/README.md @@ -1016,7 +1016,9 @@ Dashboard support for Docker deployments now includes a one-click **Cloudflare Q Notes: - Quick Tunnel URLs are temporary and change after every restart. +- Quick Tunnels are not auto-restored after an OmniRoute or container restart. Re-enable them from the dashboard when needed. - Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`. +- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained container environments. Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want a different transport. - Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container. - SQLite runs in WAL mode. `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. - The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40` (or similar) so manual stops do not cut off shutdown cleanup. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 859a428f3f..e8ee7025fc 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -525,6 +525,7 @@ post_install() { | `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs | | `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (behind HTTPS reverse proxy) | | `CLOUDFLARED_BIN` | unset | Use an existing `cloudflared` binary instead of managed download | +| `CLOUDFLARED_PROTOCOL` | `http2` | Transport for managed Quick Tunnels (`http2`, `quic`, or `auto`) | | `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit in MB | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max prompt cache entries | | `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max semantic cache entries | @@ -652,7 +653,10 @@ Returns models grouped by provider with types (`chat`, `embedding`, `image`). - Available in **Dashboard → Endpoints** for Docker and other self-hosted deployments - Creates a temporary `https://*.trycloudflare.com` URL that forwards to your current OpenAI-compatible `/v1` endpoint - First enable installs `cloudflared` only when needed; later restarts reuse the same managed binary +- Quick Tunnels are not auto-restored after an OmniRoute or container restart; re-enable them from the dashboard when needed - Tunnel URLs are ephemeral and change every time you stop/start the tunnel +- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained containers +- Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want to override the managed transport choice - Set `CLOUDFLARED_BIN` if you prefer using a preinstalled `cloudflared` binary instead of the managed download ### LLM Gateway Intelligence (Phase 9) diff --git a/src/lib/cloudflaredTunnel.ts b/src/lib/cloudflaredTunnel.ts index e3b457501e..3a06cc0f89 100644 --- a/src/lib/cloudflaredTunnel.ts +++ b/src/lib/cloudflaredTunnel.ts @@ -57,6 +57,7 @@ type BinaryResolution = { type PersistedTunnelState = { binaryPath?: string | null; installSource?: CloudflaredInstallSource | null; + ownerPid?: number | null; pid?: number | null; publicUrl?: string | null; apiUrl?: string | null; @@ -128,6 +129,9 @@ let tunnelProcess: ReturnType | null = null; let tunnelPid: number | null = null; let installPromise: Promise | null = null; let startPromise: Promise | null = null; +const NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS = [ + /failed to sufficiently increase receive buffer size/i, +] as const; function getTunnelDir() { return path.join(resolveDataDir(), "cloudflared"); @@ -264,6 +268,9 @@ export function extractCloudflaredErrorMessage(text: string) { .filter(Boolean); for (let i = lines.length - 1; i >= 0; i--) { + if (NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS.some((pattern) => pattern.test(lines[i]))) { + continue; + } if (/(?:\berror\b|\bfailed\b|\btls:\b|\bx509\b|\bcertificate\b)/i.test(lines[i])) { return lines[i]; } @@ -327,6 +334,18 @@ export function buildCloudflaredChildEnv( childEnv.SSL_CERT_DIR = defaultCertEnv.SSL_CERT_DIR; } + const requestedProtocol = String( + sourceEnv.CLOUDFLARED_PROTOCOL || sourceEnv.TUNNEL_TRANSPORT_PROTOCOL || "http2" + ) + .trim() + .toLowerCase(); + const protocol = + requestedProtocol === "quic" || requestedProtocol === "auto" ? requestedProtocol : "http2"; + + if (protocol !== "auto") { + childEnv.TUNNEL_TRANSPORT_PROTOCOL = protocol; + } + return childEnv; } @@ -334,6 +353,41 @@ export function getCloudflaredStartArgs(targetUrl: string) { return ["tunnel", "--url", targetUrl, "--no-autoupdate"]; } +function isStateOwnedByCurrentProcess(state: PersistedTunnelState) { + return !!state.ownerPid && state.ownerPid === process.pid; +} + +function hasTransientRuntimeState(state: PersistedTunnelState) { + return !!( + state.ownerPid || + state.pid || + state.publicUrl || + state.apiUrl || + state.startedAt || + state.status === "running" || + state.status === "starting" || + state.status === "error" + ); +} + +function buildStoppedState( + state: PersistedTunnelState, + binaryResolved: boolean, + targetUrl = getLocalTargetUrl() +): PersistedTunnelState { + return { + ...state, + ownerPid: null, + pid: null, + publicUrl: null, + apiUrl: null, + targetUrl, + status: binaryResolved ? "stopped" : "not_installed", + lastError: null, + startedAt: null, + }; +} + export function getCloudflaredAssetSpec( platform = process.platform, arch = process.arch @@ -549,6 +603,12 @@ async function stopExistingTunnel() { return; } + const state = await readStateFile(); + if (!isStateOwnedByCurrentProcess(state)) { + await clearPidFile(); + return; + } + const pid = await readPidFile(); if (pid && isProcessAlive(pid)) { await killPid(pid); @@ -558,9 +618,20 @@ async function stopExistingTunnel() { export async function getCloudflaredTunnelStatus(): Promise { const state = await readStateFile(); const resolved = await resolveBinary(); - const pidFromState = tunnelPid || state.pid || (await readPidFile()); + const pidFromState = + tunnelPid || (isStateOwnedByCurrentProcess(state) ? state.pid || (await readPidFile()) : null); const running = isProcessAlive(pidFromState); - const publicUrl = running ? state.publicUrl || null : null; + const needsColdStartReset = + !running && !isStateOwnedByCurrentProcess(state) && hasTransientRuntimeState(state); + const effectiveState = needsColdStartReset + ? buildStoppedState(state, !!resolved.binaryPath) + : state; + + if (needsColdStartReset) { + await writeStateFile(effectiveState); + } + + const publicUrl = running ? effectiveState.publicUrl || null : null; const phase = !getCloudflaredAssetSpec() && !resolved.binaryPath ? "unsupported" @@ -569,7 +640,7 @@ export async function getCloudflaredTunnelStatus(): Promise await writeStateFile({ binaryPath: binary.binaryPath, installSource: binary.source, + ownerPid: process.pid, pid: null, publicUrl: null, apiUrl: null, @@ -662,6 +734,7 @@ export async function startCloudflaredTunnel(): Promise const errorMessage = source === "stderr" ? extractCloudflaredErrorMessage(text) : null; if (errorMessage) { await updateStateFile({ + ownerPid: process.pid, pid: child.pid, status: "error", lastError: errorMessage, @@ -672,6 +745,7 @@ export async function startCloudflaredTunnel(): Promise const apiUrl = getTunnelApiUrl(url); await updateStateFile({ + ownerPid: process.pid, pid: child.pid, publicUrl: url, apiUrl, @@ -721,6 +795,7 @@ export async function startCloudflaredTunnel(): Promise : "Failed to start cloudflared tunnel"; await updateStateFile({ + ownerPid: process.pid, status: "error", lastError: message, }); @@ -734,12 +809,8 @@ export async function stopCloudflaredTunnel() { await stopExistingTunnel(); const current = await readStateFile(); await writeStateFile({ - ...current, - pid: null, - publicUrl: null, - apiUrl: null, - status: "stopped", - lastError: null, + ...buildStoppedState(current, !!(await resolveBinary()).binaryPath), + ownerPid: null, }); tunnelProcess = null; tunnelPid = null; diff --git a/tests/unit/cloudflaredTunnel.test.mjs b/tests/unit/cloudflaredTunnel.test.mjs index 62a51c4096..2462912501 100644 --- a/tests/unit/cloudflaredTunnel.test.mjs +++ b/tests/unit/cloudflaredTunnel.test.mjs @@ -1,10 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { buildCloudflaredChildEnv, extractCloudflaredErrorMessage, extractTryCloudflareUrl, + getCloudflaredTunnelStatus, getDefaultCloudflaredCertEnv, getCloudflaredStartArgs, getCloudflaredAssetSpec, @@ -33,6 +37,14 @@ test("extractCloudflaredErrorMessage keeps the actionable stderr line", () => { ); }); +test("extractCloudflaredErrorMessage ignores the non-actionable UDP buffer warning", () => { + const error = extractCloudflaredErrorMessage( + "WRN failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB). See https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes for details." + ); + + assert.equal(error, null); +}); + test("getCloudflaredAssetSpec resolves linux amd64 binary", () => { const spec = getCloudflaredAssetSpec("linux", "x64"); @@ -96,9 +108,56 @@ test("buildCloudflaredChildEnv keeps runtime essentials, isolates runtime dirs, TMPDIR: "/managed/runtime/tmp", TMP: "/managed/runtime/tmp", TEMP: "/managed/runtime/tmp", + TUNNEL_TRANSPORT_PROTOCOL: "http2", }); }); +test("buildCloudflaredChildEnv allows overriding the tunnel transport protocol", () => { + const env = buildCloudflaredChildEnv( + { + PATH: "/usr/bin", + CLOUDFLARED_PROTOCOL: "quic", + }, + { + runtimeRoot: "/managed/runtime", + homeDir: "/managed/runtime/home", + configDir: "/managed/runtime/config", + cacheDir: "/managed/runtime/cache", + dataDir: "/managed/runtime/data", + tempDir: "/managed/runtime/tmp", + userProfileDir: "/managed/runtime/userprofile", + appDataDir: "/managed/runtime/userprofile/AppData/Roaming", + localAppDataDir: "/managed/runtime/userprofile/AppData/Local", + }, + {} + ); + + assert.equal(env.TUNNEL_TRANSPORT_PROTOCOL, "quic"); +}); + +test("buildCloudflaredChildEnv preserves auto negotiation when explicitly requested", () => { + const env = buildCloudflaredChildEnv( + { + PATH: "/usr/bin", + CLOUDFLARED_PROTOCOL: "auto", + }, + { + runtimeRoot: "/managed/runtime", + homeDir: "/managed/runtime/home", + configDir: "/managed/runtime/config", + cacheDir: "/managed/runtime/cache", + dataDir: "/managed/runtime/data", + tempDir: "/managed/runtime/tmp", + userProfileDir: "/managed/runtime/userprofile", + appDataDir: "/managed/runtime/userprofile/AppData/Roaming", + localAppDataDir: "/managed/runtime/userprofile/AppData/Local", + }, + {} + ); + + assert.equal(env.TUNNEL_TRANSPORT_PROTOCOL, undefined); +}); + test("getDefaultCloudflaredCertEnv detects common CA bundle paths", () => { const env = getDefaultCloudflaredCertEnv((candidate) => ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs"].includes(candidate) @@ -134,7 +193,7 @@ test("buildCloudflaredChildEnv injects discovered CA paths when the parent env o assert.equal(env.SSL_CERT_DIR, "/etc/ssl/certs"); }); -test("getCloudflaredStartArgs relies on cloudflared protocol auto-negotiation", () => { +test("getCloudflaredStartArgs keeps protocol selection out of argv", () => { assert.deepEqual(getCloudflaredStartArgs("http://127.0.0.1:20128"), [ "tunnel", "--url", @@ -142,3 +201,72 @@ test("getCloudflaredStartArgs relies on cloudflared protocol auto-negotiation", "--no-autoupdate", ]); }); + +test("getCloudflaredTunnelStatus resets stale runtime state from a previous server process", async () => { + const originalDataDir = process.env.DATA_DIR; + const originalBinary = process.env.CLOUDFLARED_BIN; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-cloudflared-")); + const binDir = path.join(tempDir, "bin"); + const binaryPath = path.join(binDir, "cloudflared"); + const stateDir = path.join(tempDir, "cloudflared"); + const statePath = path.join(stateDir, "quick-tunnel-state.json"); + + process.env.DATA_DIR = tempDir; + process.env.CLOUDFLARED_BIN = binaryPath; + + try { + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(binaryPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.writeFile( + statePath, + JSON.stringify( + { + binaryPath, + installSource: "env", + ownerPid: process.pid + 100000, + pid: process.pid, + publicUrl: "https://stale.trycloudflare.com", + apiUrl: "https://stale.trycloudflare.com/v1", + targetUrl: "http://127.0.0.1:20128", + status: "running", + lastError: + "failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB)", + startedAt: "2026-04-02T00:07:16.000Z", + }, + null, + 2 + ) + "\n", + "utf8" + ); + + const status = await getCloudflaredTunnelStatus(); + const persisted = JSON.parse(await fs.readFile(statePath, "utf8")); + + assert.equal(status.running, false); + assert.equal(status.phase, "stopped"); + assert.equal(status.publicUrl, null); + assert.equal(status.apiUrl, null); + assert.equal(status.lastError, null); + assert.equal(persisted.ownerPid, null); + assert.equal(persisted.pid, null); + assert.equal(persisted.publicUrl, null); + assert.equal(persisted.apiUrl, null); + assert.equal(persisted.status, "stopped"); + assert.equal(persisted.lastError, null); + } finally { + if (originalDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = originalDataDir; + } + + if (originalBinary === undefined) { + delete process.env.CLOUDFLARED_BIN; + } else { + process.env.CLOUDFLARED_BIN = originalBinary; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +});