@iamken/cloudtunnel 0.10.5 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -200,6 +200,10 @@ async function cfRequest(token, method, path, body) {
200
200
  } catch (err) {
201
201
  const reason = fetchErrorReason(err);
202
202
  say.debug(`[cf] ${method} ${base}${path} -> network error: ${reason}${viaRelay ? " (relay)" : ""}`);
203
+ if (attempt < MAX_ATTEMPTS) {
204
+ await sleep(2 ** attempt * 500);
205
+ continue;
206
+ }
203
207
  throw new CliError(`Could not reach the Cloudflare API (${reason})${viaRelay ? ` via relay ${base}` : ""}.`, {
204
208
  hint: viaRelay ? "is the relay tunnel up and the base URL correct? run with CLOUDTUNNEL_DEBUG=1" : void 0
205
209
  });
@@ -272,4 +276,4 @@ export {
272
276
  cfRequest,
273
277
  cfPaginate
274
278
  };
275
- //# sourceMappingURL=chunk-ZIUYW2HQ.js.map
279
+ //# sourceMappingURL=chunk-7XACBYL7.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/ui/errors.ts","../src/ui/output.ts","../src/config/store.ts","../src/config/paths.ts","../src/config/api-base.ts","../src/config/relay-secret.ts","../src/cloudflare/client.ts"],"sourcesContent":["import pc from \"picocolors\";\n\n/**\n * A user-facing CLI error. The message is printed as-is (no stack trace) and\n * `exitCode` drives the process exit code. Use `hint` to tell the user exactly\n * which command to run next — every error should be actionable.\n */\nexport class CliError extends Error {\n readonly exitCode: number;\n readonly hint?: string;\n /** HTTP status when this wraps a Cloudflare API error (lets callers tell a\n * genuine 404 \"already gone\" from a transient failure). */\n readonly status?: number;\n\n constructor(message: string, opts: { exitCode?: number; hint?: string; status?: number } = {}) {\n super(message);\n this.name = \"CliError\";\n this.exitCode = opts.exitCode ?? 1;\n this.hint = opts.hint;\n this.status = opts.status;\n }\n}\n\n/**\n * Best-effort concise reason from a thrown `fetch`/undici error. Node's fetch\n * throws a generic `TypeError: fetch failed` and buries the real cause (DNS,\n * refused, TLS, timeout, bad URL) in `.cause` — surface it so \"network error\"\n * becomes actionable (e.g. `ENOTFOUND`, `ECONNREFUSED`, `UND_ERR_CONNECT_TIMEOUT`,\n * `self-signed certificate`, `Invalid URL`).\n */\nexport function fetchErrorReason(err: unknown): string {\n const cause = (err as { cause?: { code?: string; message?: string } })?.cause;\n return cause?.code ?? cause?.message ?? (err as Error)?.message ?? \"unknown\";\n}\n\n/** Print an error and return the exit code. Known CliErrors print cleanly; the\n * rest print their message plus a note that it was unexpected. */\nexport function reportError(err: unknown): number {\n if (err instanceof CliError) {\n console.error(pc.red(`✗ ${err.message}`));\n if (err.hint) console.error(pc.dim(` → ${err.hint}`));\n return err.exitCode;\n }\n const message = err instanceof Error ? err.message : String(err);\n console.error(pc.red(`✗ ${message}`));\n console.error(pc.dim(\" (unexpected error — please report if this persists)\"));\n return 1;\n}\n","import pc from \"picocolors\";\nimport Table from \"cli-table3\";\nimport { cancel, confirm as clackConfirm, intro, isCancel, note, outro, select, spinner } from \"@clack/prompts\";\nimport { CliError } from \"./errors.js\";\n\n// Re-export the clack primitives used to build modern multi-step flows.\nexport { intro, note, outro, spinner };\n\n/** Yes/no prompt (TTY). Cancel (Ctrl-C) counts as \"no\". */\nexport async function confirm(message: string): Promise<boolean> {\n const answer = await clackConfirm({ message });\n return !isCancel(answer) && answer === true;\n}\n\n/** Redact a secret to `••••{last4}` so tokens never appear in output/logs. */\nexport function redactToken(token: string): string {\n if (!token) return \"\";\n const last4 = token.length > 4 ? token.slice(-4) : token;\n return `••••${last4}`;\n}\n\n// Lightweight one-off lines for non-flow commands (ls, zones, status, …).\nexport const say = {\n info: (msg: string) => console.log(msg),\n ok: (msg: string) => console.log(pc.green(`✓ ${msg}`)),\n warn: (msg: string) => console.warn(pc.yellow(`! ${msg}`)),\n dim: (msg: string) => console.log(pc.dim(msg)),\n step: (msg: string) => console.log(pc.cyan(`→ ${msg}`)),\n // Opt-in request/diagnostic trace. Gated by CLOUDTUNNEL_DEBUG and written to\n // STDERR so it never mixes into command stdout. Never pass a secret/token here.\n debug: (msg: string) => {\n if (process.env.CLOUDTUNNEL_DEBUG) console.error(pc.dim(msg));\n },\n};\n\nexport const dim = (s: string): string => pc.dim(s);\n\n/** Format a live tunnel as `https://host → proto://localhost:port`. */\nexport function formatRoute(host: string, target: string): string {\n return `${pc.green(pc.bold(`https://${host}`))} ${pc.dim(\"→\")} ${pc.cyan(target)}`;\n}\n\n/** Render a simple table. `head` = column titles, `rows` = string cells. */\nexport function printTable(head: string[], rows: string[][]): void {\n const table = new Table({\n head: head.map((h) => pc.bold(h)),\n style: { head: [], border: [] },\n });\n for (const row of rows) table.push(row);\n console.log(table.toString());\n}\n\n/**\n * Modern arrow-key single-select (↑/↓ to move, Enter to choose). Callers must\n * guard non-TTY before calling. Ctrl-C cancels cleanly (exit 130).\n */\nexport async function selectOne<T>(\n message: string,\n items: T[],\n label: (item: T) => string,\n): Promise<T> {\n // Use the item index as the (primitive) option value to avoid clack's\n // conditional Option<T> type fighting the generic, then map back.\n const value = await select({\n message,\n options: items.map((item, i) => ({ value: String(i), label: label(item) })),\n });\n if (isCancel(value)) {\n cancel(\"Cancelled.\");\n throw new CliError(\"Cancelled.\", { exitCode: 130 });\n }\n return items[Number(value)]!;\n}\n","import { chmodSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { CliError } from \"../ui/errors.js\";\nimport { configFile, ensureDirs } from \"./paths.js\";\n\nexport interface CloudtunnelConfig {\n apiToken?: string;\n accountId?: string;\n defaultZone?: string;\n /** Override the Cloudflare API base — point at a relay when the direct control\n * plane is blocked. See `config/api-base.ts`. */\n apiBase?: string;\n /** Shared secret required by / sent to the relay proxy. See `config/relay-secret.ts`. */\n relaySecret?: string;\n}\n\nexport interface Credentials {\n apiToken: string;\n accountId?: string;\n defaultZone?: string;\n}\n\n/** Read config.json (missing/invalid → empty config, never throws). */\nexport function loadConfig(): CloudtunnelConfig {\n try {\n return JSON.parse(readFileSync(configFile, \"utf8\")) as CloudtunnelConfig;\n } catch {\n return {};\n }\n}\n\n/** Persist config.json with owner-only perms (0600). */\nexport function saveConfig(config: CloudtunnelConfig): void {\n ensureDirs();\n writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 });\n chmodSync(configFile, 0o600); // enforce even if the file pre-existed\n}\n\n/**\n * Resolve credentials: env vars override the config file. Throws an actionable\n * CliError if no API token is available anywhere.\n */\nexport function getCredentials(): Credentials {\n const config = loadConfig();\n const apiToken = process.env.CLOUDFLARE_API_TOKEN ?? config.apiToken;\n const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? config.accountId;\n if (!apiToken) {\n throw new CliError(\"Not authenticated with Cloudflare.\", {\n hint: \"run `cloudtunnel login` (or set CLOUDFLARE_API_TOKEN)\",\n });\n }\n return { apiToken, accountId, defaultZone: config.defaultZone };\n}\n","import envPaths from \"env-paths\";\nimport { mkdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n// `~/.config/cloudtunnel/` (XDG). suffix:'' avoids env-paths' default \"-nodejs\".\nconst paths = envPaths(\"cloudtunnel\", { suffix: \"\" });\n\nexport const configDir = paths.config;\nexport const configFile = join(configDir, \"config.json\");\nexport const registryFile = join(configDir, \"tunnels.json\");\nexport const scanCacheFile = join(configDir, \"unmanaged-scan.json\");\nexport const profilesFile = join(configDir, \"profiles.json\");\nexport const binDir = join(configDir, \"bin\");\nexport const logDir = join(configDir, \"logs\");\n\n/** Create the app dirs with owner-only perms (secrets live here). Idempotent. */\nexport function ensureDirs(): void {\n for (const dir of [configDir, binDir, logDir]) {\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n }\n}\n","import { CliError } from \"../ui/errors.js\";\nimport { loadConfig } from \"./store.js\";\n\n/** Default Cloudflare management API base — no trailing slash (callers append a\n * path that starts with `/`). */\nexport const DEFAULT_API_BASE = \"https://api.cloudflare.com/client/v4\";\n\n/**\n * Resolve the Cloudflare API base URL. Precedence: env `CLOUDTUNNEL_API_BASE` →\n * `config.apiBase` → default. A trailing slash is stripped so `${base}${path}`\n * (path starts `/`) never yields `//…`.\n *\n * Point this at a relay's `https://<relay>/client/v4` when the direct control\n * plane (api.cloudflare.com) is blocked but a data-plane tunnel to the relay is\n * not — every management call then rides the tunnel instead of hitting CF direct.\n */\nexport function getApiBase(): string {\n // `||` (not `??`) so an empty/whitespace env or config value falls through\n // instead of yielding a broken \"\" base.\n const raw = (process.env.CLOUDTUNNEL_API_BASE?.trim() || loadConfig().apiBase || DEFAULT_API_BASE).replace(/\\/+$/, \"\");\n // Fail fast on a malformed base (e.g. a scheme-less host set via env) with a\n // clear message instead of a cryptic `fetch` \"network error\" later.\n if (!isHttpUrl(raw)) {\n throw new CliError(`Invalid Cloudflare API base \"${raw}\".`, {\n hint: \"CLOUDTUNNEL_API_BASE must be a full http(s) URL, e.g. https://cfapi.example.com/client/v4\",\n });\n }\n return raw;\n}\n\n/** True if `s` parses as an http(s) URL — validates `login --api-base`. */\nexport function isHttpUrl(s: string): boolean {\n try {\n const u = new URL(s);\n return u.protocol === \"http:\" || u.protocol === \"https:\";\n } catch {\n return false;\n }\n}\n","import { randomBytes } from \"node:crypto\";\nimport { loadConfig, saveConfig } from \"./store.js\";\n\n/** Header the CLIENT sends and the RELAY requires — the relay's shared secret.\n * Single source of truth, imported by both the client transport and the relay\n * proxy so the two sides can never drift. */\nexport const RELAY_SECRET_HEADER = \"X-CT-Relay-Secret\";\n\n/**\n * The relay shared secret, if configured. Precedence: env\n * `CLOUDTUNNEL_RELAY_SECRET` → `config.relaySecret` → undefined. The CLIENT\n * attaches it to every CF API call; the RELAY rejects calls that lack it.\n */\nexport function getRelaySecret(): string | undefined {\n return process.env.CLOUDTUNNEL_RELAY_SECRET ?? loadConfig().relaySecret;\n}\n\n/**\n * Return the relay secret, generating + persisting one on first use so a restart\n * or boot service keeps the same value (a changed secret would break the client).\n * Persisted to 0600 config via a MERGE-save (never clobbers apiToken/apiBase).\n * An env-provided secret is returned as-is — env stays the source of truth and\n * nothing is written.\n */\nexport function ensureRelaySecret(): string {\n const existing = getRelaySecret();\n if (existing) return existing;\n const secret = randomBytes(24).toString(\"base64url\"); // 192-bit, url-safe\n const prev = loadConfig();\n saveConfig({ ...prev, relaySecret: secret });\n return secret;\n}\n","import { CliError, fetchErrorReason } from \"../ui/errors.js\";\nimport { say } from \"../ui/output.js\";\nimport { getCredentials } from \"../config/store.js\";\nimport { getApiBase, DEFAULT_API_BASE } from \"../config/api-base.js\";\nimport { RELAY_SECRET_HEADER, getRelaySecret } from \"../config/relay-secret.js\";\n\nconst RETRYABLE = new Set([429, 500, 502, 503, 504]);\nconst MAX_ATTEMPTS = 4;\n\n/** Resolved Cloudflare context for account-scoped calls. */\nexport interface Cf {\n token: string;\n accountId: string;\n}\n\ninterface CfEnvelope<T> {\n success: boolean;\n result: T;\n result_info?: { page: number; total_pages?: number; per_page: number; count: number };\n errors?: { message: string }[];\n}\n\n/** Token + account id required for tunnel ops. Throws actionably if unresolved. */\nexport function resolveCf(): Cf {\n const creds = getCredentials();\n if (!creds.accountId) {\n throw new CliError(\"No Cloudflare account id resolved.\", {\n hint: \"run `cloudtunnel login` (or set CLOUDFLARE_ACCOUNT_ID)\",\n });\n }\n return { token: creds.apiToken, accountId: creds.accountId };\n}\n\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n/**\n * Single Cloudflare API call with backoff on 429/5xx (honors Retry-After).\n * Errors are sanitized — the bearer token is only ever sent in the header and\n * never appears in a thrown message.\n */\nexport async function cfRequest<T>(\n token: string,\n method: string,\n path: string,\n body?: unknown,\n): Promise<CfEnvelope<T>> {\n // Base + relay secret resolved once per call (env → config → default). The\n // secret header rides only when the base is actually a relay (non-default), so\n // it's never sent to api.cloudflare.com; unset base ⇒ identical to today.\n const base = getApiBase();\n const viaRelay = base !== DEFAULT_API_BASE;\n const secret = getRelaySecret();\n const headers: Record<string, string> = {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n };\n if (secret && viaRelay) headers[RELAY_SECRET_HEADER] = secret;\n for (let attempt = 0; ; attempt++) {\n let res: Response;\n try {\n res = await fetch(`${base}${path}`, {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n } catch (err) {\n const reason = fetchErrorReason(err);\n say.debug(`[cf] ${method} ${base}${path} -> network error: ${reason}${viaRelay ? \" (relay)\" : \"\"}`);\n throw new CliError(`Could not reach the Cloudflare API (${reason})${viaRelay ? ` via relay ${base}` : \"\"}.`, {\n hint: viaRelay ? \"is the relay tunnel up and the base URL correct? run with CLOUDTUNNEL_DEBUG=1\" : undefined,\n });\n }\n say.debug(`[cf] ${method} ${base}${path} -> ${res.status}${viaRelay ? \" (relay)\" : \"\"}`);\n if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {\n const retryAfter = Number(res.headers.get(\"retry-after\")) || 0;\n await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500);\n continue;\n }\n const env = (await res.json().catch(() => ({}))) as CfEnvelope<T> & { error?: string };\n if (!res.ok || !env.success) {\n // A relay rejects with its own `{error}` shape (not CF's `{errors}`) — surface\n // it so a relay/secret failure isn't misread as a Cloudflare API error.\n if (viaRelay && env.error && !env.errors) {\n throw new CliError(`Relay rejected the request (${res.status}): ${env.error}.`, {\n status: res.status,\n hint: res.status === 403 ? \"does CLOUDTUNNEL_RELAY_SECRET match the relay's secret?\" : `relay base: ${base}`,\n });\n }\n const msg = env.errors?.[0]?.message ?? `HTTP ${res.status}`;\n throw new CliError(`Cloudflare API error: ${msg}`, { status: res.status });\n }\n return env;\n }\n}\n\n/**\n * Fetch every page of a list endpoint. Robust when `total_pages` is absent:\n * stops when a page returns fewer than per_page items.\n */\nexport async function cfPaginate<T>(token: string, basePath: string): Promise<T[]> {\n const sep = basePath.includes(\"?\") ? \"&\" : \"?\";\n const out: T[] = [];\n for (let page = 1; ; page++) {\n const env = await cfRequest<T[]>(token, \"GET\", `${basePath}${sep}per_page=50&page=${page}`);\n const items = env.result ?? [];\n out.push(...items);\n const perPage = env.result_info?.per_page ?? 50;\n const totalPages = env.result_info?.total_pages;\n const more = totalPages ? page < totalPages : items.length === perPage;\n if (items.length === 0 || !more) break;\n }\n return out;\n}\n"],"mappings":"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","names":["pc","pc"]}
1
+ {"version":3,"sources":["../src/ui/errors.ts","../src/ui/output.ts","../src/config/store.ts","../src/config/paths.ts","../src/config/api-base.ts","../src/config/relay-secret.ts","../src/cloudflare/client.ts"],"sourcesContent":["import pc from \"picocolors\";\n\n/**\n * A user-facing CLI error. The message is printed as-is (no stack trace) and\n * `exitCode` drives the process exit code. Use `hint` to tell the user exactly\n * which command to run next — every error should be actionable.\n */\nexport class CliError extends Error {\n readonly exitCode: number;\n readonly hint?: string;\n /** HTTP status when this wraps a Cloudflare API error (lets callers tell a\n * genuine 404 \"already gone\" from a transient failure). */\n readonly status?: number;\n\n constructor(message: string, opts: { exitCode?: number; hint?: string; status?: number } = {}) {\n super(message);\n this.name = \"CliError\";\n this.exitCode = opts.exitCode ?? 1;\n this.hint = opts.hint;\n this.status = opts.status;\n }\n}\n\n/**\n * Best-effort concise reason from a thrown `fetch`/undici error. Node's fetch\n * throws a generic `TypeError: fetch failed` and buries the real cause (DNS,\n * refused, TLS, timeout, bad URL) in `.cause` — surface it so \"network error\"\n * becomes actionable (e.g. `ENOTFOUND`, `ECONNREFUSED`, `UND_ERR_CONNECT_TIMEOUT`,\n * `self-signed certificate`, `Invalid URL`).\n */\nexport function fetchErrorReason(err: unknown): string {\n const cause = (err as { cause?: { code?: string; message?: string } })?.cause;\n return cause?.code ?? cause?.message ?? (err as Error)?.message ?? \"unknown\";\n}\n\n/** Print an error and return the exit code. Known CliErrors print cleanly; the\n * rest print their message plus a note that it was unexpected. */\nexport function reportError(err: unknown): number {\n if (err instanceof CliError) {\n console.error(pc.red(`✗ ${err.message}`));\n if (err.hint) console.error(pc.dim(` → ${err.hint}`));\n return err.exitCode;\n }\n const message = err instanceof Error ? err.message : String(err);\n console.error(pc.red(`✗ ${message}`));\n console.error(pc.dim(\" (unexpected error — please report if this persists)\"));\n return 1;\n}\n","import pc from \"picocolors\";\nimport Table from \"cli-table3\";\nimport { cancel, confirm as clackConfirm, intro, isCancel, note, outro, select, spinner } from \"@clack/prompts\";\nimport { CliError } from \"./errors.js\";\n\n// Re-export the clack primitives used to build modern multi-step flows.\nexport { intro, note, outro, spinner };\n\n/** Yes/no prompt (TTY). Cancel (Ctrl-C) counts as \"no\". */\nexport async function confirm(message: string): Promise<boolean> {\n const answer = await clackConfirm({ message });\n return !isCancel(answer) && answer === true;\n}\n\n/** Redact a secret to `••••{last4}` so tokens never appear in output/logs. */\nexport function redactToken(token: string): string {\n if (!token) return \"\";\n const last4 = token.length > 4 ? token.slice(-4) : token;\n return `••••${last4}`;\n}\n\n// Lightweight one-off lines for non-flow commands (ls, zones, status, …).\nexport const say = {\n info: (msg: string) => console.log(msg),\n ok: (msg: string) => console.log(pc.green(`✓ ${msg}`)),\n warn: (msg: string) => console.warn(pc.yellow(`! ${msg}`)),\n dim: (msg: string) => console.log(pc.dim(msg)),\n step: (msg: string) => console.log(pc.cyan(`→ ${msg}`)),\n // Opt-in request/diagnostic trace. Gated by CLOUDTUNNEL_DEBUG and written to\n // STDERR so it never mixes into command stdout. Never pass a secret/token here.\n debug: (msg: string) => {\n if (process.env.CLOUDTUNNEL_DEBUG) console.error(pc.dim(msg));\n },\n};\n\nexport const dim = (s: string): string => pc.dim(s);\n\n/** Format a live tunnel as `https://host → proto://localhost:port`. */\nexport function formatRoute(host: string, target: string): string {\n return `${pc.green(pc.bold(`https://${host}`))} ${pc.dim(\"→\")} ${pc.cyan(target)}`;\n}\n\n/** Render a simple table. `head` = column titles, `rows` = string cells. */\nexport function printTable(head: string[], rows: string[][]): void {\n const table = new Table({\n head: head.map((h) => pc.bold(h)),\n style: { head: [], border: [] },\n });\n for (const row of rows) table.push(row);\n console.log(table.toString());\n}\n\n/**\n * Modern arrow-key single-select (↑/↓ to move, Enter to choose). Callers must\n * guard non-TTY before calling. Ctrl-C cancels cleanly (exit 130).\n */\nexport async function selectOne<T>(\n message: string,\n items: T[],\n label: (item: T) => string,\n): Promise<T> {\n // Use the item index as the (primitive) option value to avoid clack's\n // conditional Option<T> type fighting the generic, then map back.\n const value = await select({\n message,\n options: items.map((item, i) => ({ value: String(i), label: label(item) })),\n });\n if (isCancel(value)) {\n cancel(\"Cancelled.\");\n throw new CliError(\"Cancelled.\", { exitCode: 130 });\n }\n return items[Number(value)]!;\n}\n","import { chmodSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { CliError } from \"../ui/errors.js\";\nimport { configFile, ensureDirs } from \"./paths.js\";\n\nexport interface CloudtunnelConfig {\n apiToken?: string;\n accountId?: string;\n defaultZone?: string;\n /** Override the Cloudflare API base — point at a relay when the direct control\n * plane is blocked. See `config/api-base.ts`. */\n apiBase?: string;\n /** Shared secret required by / sent to the relay proxy. See `config/relay-secret.ts`. */\n relaySecret?: string;\n}\n\nexport interface Credentials {\n apiToken: string;\n accountId?: string;\n defaultZone?: string;\n}\n\n/** Read config.json (missing/invalid → empty config, never throws). */\nexport function loadConfig(): CloudtunnelConfig {\n try {\n return JSON.parse(readFileSync(configFile, \"utf8\")) as CloudtunnelConfig;\n } catch {\n return {};\n }\n}\n\n/** Persist config.json with owner-only perms (0600). */\nexport function saveConfig(config: CloudtunnelConfig): void {\n ensureDirs();\n writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 });\n chmodSync(configFile, 0o600); // enforce even if the file pre-existed\n}\n\n/**\n * Resolve credentials: env vars override the config file. Throws an actionable\n * CliError if no API token is available anywhere.\n */\nexport function getCredentials(): Credentials {\n const config = loadConfig();\n const apiToken = process.env.CLOUDFLARE_API_TOKEN ?? config.apiToken;\n const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? config.accountId;\n if (!apiToken) {\n throw new CliError(\"Not authenticated with Cloudflare.\", {\n hint: \"run `cloudtunnel login` (or set CLOUDFLARE_API_TOKEN)\",\n });\n }\n return { apiToken, accountId, defaultZone: config.defaultZone };\n}\n","import envPaths from \"env-paths\";\nimport { mkdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n// `~/.config/cloudtunnel/` (XDG). suffix:'' avoids env-paths' default \"-nodejs\".\nconst paths = envPaths(\"cloudtunnel\", { suffix: \"\" });\n\nexport const configDir = paths.config;\nexport const configFile = join(configDir, \"config.json\");\nexport const registryFile = join(configDir, \"tunnels.json\");\nexport const scanCacheFile = join(configDir, \"unmanaged-scan.json\");\nexport const profilesFile = join(configDir, \"profiles.json\");\nexport const binDir = join(configDir, \"bin\");\nexport const logDir = join(configDir, \"logs\");\n\n/** Create the app dirs with owner-only perms (secrets live here). Idempotent. */\nexport function ensureDirs(): void {\n for (const dir of [configDir, binDir, logDir]) {\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n }\n}\n","import { CliError } from \"../ui/errors.js\";\nimport { loadConfig } from \"./store.js\";\n\n/** Default Cloudflare management API base — no trailing slash (callers append a\n * path that starts with `/`). */\nexport const DEFAULT_API_BASE = \"https://api.cloudflare.com/client/v4\";\n\n/**\n * Resolve the Cloudflare API base URL. Precedence: env `CLOUDTUNNEL_API_BASE` →\n * `config.apiBase` → default. A trailing slash is stripped so `${base}${path}`\n * (path starts `/`) never yields `//…`.\n *\n * Point this at a relay's `https://<relay>/client/v4` when the direct control\n * plane (api.cloudflare.com) is blocked but a data-plane tunnel to the relay is\n * not — every management call then rides the tunnel instead of hitting CF direct.\n */\nexport function getApiBase(): string {\n // `||` (not `??`) so an empty/whitespace env or config value falls through\n // instead of yielding a broken \"\" base.\n const raw = (process.env.CLOUDTUNNEL_API_BASE?.trim() || loadConfig().apiBase || DEFAULT_API_BASE).replace(/\\/+$/, \"\");\n // Fail fast on a malformed base (e.g. a scheme-less host set via env) with a\n // clear message instead of a cryptic `fetch` \"network error\" later.\n if (!isHttpUrl(raw)) {\n throw new CliError(`Invalid Cloudflare API base \"${raw}\".`, {\n hint: \"CLOUDTUNNEL_API_BASE must be a full http(s) URL, e.g. https://cfapi.example.com/client/v4\",\n });\n }\n return raw;\n}\n\n/** True if `s` parses as an http(s) URL — validates `login --api-base`. */\nexport function isHttpUrl(s: string): boolean {\n try {\n const u = new URL(s);\n return u.protocol === \"http:\" || u.protocol === \"https:\";\n } catch {\n return false;\n }\n}\n","import { randomBytes } from \"node:crypto\";\nimport { loadConfig, saveConfig } from \"./store.js\";\n\n/** Header the CLIENT sends and the RELAY requires — the relay's shared secret.\n * Single source of truth, imported by both the client transport and the relay\n * proxy so the two sides can never drift. */\nexport const RELAY_SECRET_HEADER = \"X-CT-Relay-Secret\";\n\n/**\n * The relay shared secret, if configured. Precedence: env\n * `CLOUDTUNNEL_RELAY_SECRET` → `config.relaySecret` → undefined. The CLIENT\n * attaches it to every CF API call; the RELAY rejects calls that lack it.\n */\nexport function getRelaySecret(): string | undefined {\n return process.env.CLOUDTUNNEL_RELAY_SECRET ?? loadConfig().relaySecret;\n}\n\n/**\n * Return the relay secret, generating + persisting one on first use so a restart\n * or boot service keeps the same value (a changed secret would break the client).\n * Persisted to 0600 config via a MERGE-save (never clobbers apiToken/apiBase).\n * An env-provided secret is returned as-is — env stays the source of truth and\n * nothing is written.\n */\nexport function ensureRelaySecret(): string {\n const existing = getRelaySecret();\n if (existing) return existing;\n const secret = randomBytes(24).toString(\"base64url\"); // 192-bit, url-safe\n const prev = loadConfig();\n saveConfig({ ...prev, relaySecret: secret });\n return secret;\n}\n","import { CliError, fetchErrorReason } from \"../ui/errors.js\";\nimport { say } from \"../ui/output.js\";\nimport { getCredentials } from \"../config/store.js\";\nimport { getApiBase, DEFAULT_API_BASE } from \"../config/api-base.js\";\nimport { RELAY_SECRET_HEADER, getRelaySecret } from \"../config/relay-secret.js\";\n\nconst RETRYABLE = new Set([429, 500, 502, 503, 504]);\nconst MAX_ATTEMPTS = 4;\n\n/** Resolved Cloudflare context for account-scoped calls. */\nexport interface Cf {\n token: string;\n accountId: string;\n}\n\ninterface CfEnvelope<T> {\n success: boolean;\n result: T;\n result_info?: { page: number; total_pages?: number; per_page: number; count: number };\n errors?: { message: string }[];\n}\n\n/** Token + account id required for tunnel ops. Throws actionably if unresolved. */\nexport function resolveCf(): Cf {\n const creds = getCredentials();\n if (!creds.accountId) {\n throw new CliError(\"No Cloudflare account id resolved.\", {\n hint: \"run `cloudtunnel login` (or set CLOUDFLARE_ACCOUNT_ID)\",\n });\n }\n return { token: creds.apiToken, accountId: creds.accountId };\n}\n\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n/**\n * Single Cloudflare API call with backoff on network dropouts and 429/5xx (honors Retry-After).\n * Errors are sanitized — the bearer token is only ever sent in the header and\n * never appears in a thrown message.\n */\nexport async function cfRequest<T>(\n token: string,\n method: string,\n path: string,\n body?: unknown,\n): Promise<CfEnvelope<T>> {\n const base = getApiBase();\n const viaRelay = base !== DEFAULT_API_BASE;\n const secret = getRelaySecret();\n const headers: Record<string, string> = {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n };\n if (secret && viaRelay) headers[RELAY_SECRET_HEADER] = secret;\n for (let attempt = 0; ; attempt++) {\n let res: Response;\n try {\n res = await fetch(`${base}${path}`, {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n } catch (err) {\n const reason = fetchErrorReason(err);\n say.debug(`[cf] ${method} ${base}${path} -> network error: ${reason}${viaRelay ? \" (relay)\" : \"\"}`);\n if (attempt < MAX_ATTEMPTS) {\n await sleep(2 ** attempt * 500);\n continue;\n }\n throw new CliError(`Could not reach the Cloudflare API (${reason})${viaRelay ? ` via relay ${base}` : \"\"}.`, {\n hint: viaRelay ? \"is the relay tunnel up and the base URL correct? run with CLOUDTUNNEL_DEBUG=1\" : undefined,\n });\n }\n say.debug(`[cf] ${method} ${base}${path} -> ${res.status}${viaRelay ? \" (relay)\" : \"\"}`);\n if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {\n const retryAfter = Number(res.headers.get(\"retry-after\")) || 0;\n await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500);\n continue;\n }\n const env = (await res.json().catch(() => ({}))) as CfEnvelope<T> & { error?: string };\n if (!res.ok || !env.success) {\n if (viaRelay && env.error && !env.errors) {\n throw new CliError(`Relay rejected the request (${res.status}): ${env.error}.`, {\n status: res.status,\n hint: res.status === 403 ? \"does CLOUDTUNNEL_RELAY_SECRET match the relay's secret?\" : `relay base: ${base}`,\n });\n }\n const msg = env.errors?.[0]?.message ?? `HTTP ${res.status}`;\n throw new CliError(`Cloudflare API error: ${msg}`, { status: res.status });\n }\n return env;\n }\n}\n\n/**\n * Fetch every page of a list endpoint. Robust when `total_pages` is absent:\n * stops when a page returns fewer than per_page items.\n */\nexport async function cfPaginate<T>(token: string, basePath: string): Promise<T[]> {\n const sep = basePath.includes(\"?\") ? \"&\" : \"?\";\n const out: T[] = [];\n for (let page = 1; ; page++) {\n const env = await cfRequest<T[]>(token, \"GET\", `${basePath}${sep}per_page=50&page=${page}`);\n const items = env.result ?? [];\n out.push(...items);\n const perPage = env.result_info?.per_page ?? 50;\n const totalPages = env.result_info?.total_pages;\n const more = totalPages ? page < totalPages : items.length === perPage;\n if (items.length === 0 || !more) break;\n }\n return out;\n}\n"],"mappings":"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","names":["pc","pc"]}
@@ -3,7 +3,7 @@ import {
3
3
  CliError,
4
4
  cfPaginate,
5
5
  cfRequest
6
- } from "./chunk-ZIUYW2HQ.js";
6
+ } from "./chunk-7XACBYL7.js";
7
7
 
8
8
  // src/cloudflare/dns.ts
9
9
  var MANAGED_DNS_COMMENT = "managed-by:cloudtunnel";
@@ -54,4 +54,4 @@ export {
54
54
  listCargoCnames,
55
55
  deleteDnsRecord
56
56
  };
57
- //# sourceMappingURL=chunk-IDTF7SUY.js.map
57
+ //# sourceMappingURL=chunk-ABLZ7XRZ.js.map
@@ -3,7 +3,7 @@ import {
3
3
  CliError,
4
4
  cfPaginate,
5
5
  cfRequest
6
- } from "./chunk-ZIUYW2HQ.js";
6
+ } from "./chunk-7XACBYL7.js";
7
7
 
8
8
  // src/cloudflare/zones.ts
9
9
  function listZones(token) {
@@ -24,4 +24,4 @@ export {
24
24
  listZones,
25
25
  resolveZone
26
26
  };
27
- //# sourceMappingURL=chunk-NB3OEY2P.js.map
27
+ //# sourceMappingURL=chunk-OT3UCBBF.js.map
@@ -7,8 +7,8 @@ import {
7
7
  findCname,
8
8
  isManagedDns,
9
9
  listCargoCnames
10
- } from "./chunk-IDTF7SUY.js";
11
- import "./chunk-ZIUYW2HQ.js";
10
+ } from "./chunk-ABLZ7XRZ.js";
11
+ import "./chunk-7XACBYL7.js";
12
12
  export {
13
13
  MANAGED_DNS_COMMENT,
14
14
  cargoTarget,
@@ -18,4 +18,4 @@ export {
18
18
  isManagedDns,
19
19
  listCargoCnames
20
20
  };
21
- //# sourceMappingURL=dns-2F5SYQNX.js.map
21
+ //# sourceMappingURL=dns-DKPXBRCH.js.map
package/dist/index.js CHANGED
@@ -2,13 +2,13 @@
2
2
  import {
3
3
  listZones,
4
4
  resolveZone
5
- } from "./chunk-NB3OEY2P.js";
5
+ } from "./chunk-OT3UCBBF.js";
6
6
  import {
7
7
  createCname,
8
8
  deleteDnsRecord,
9
9
  findCname,
10
10
  isManagedDns
11
- } from "./chunk-IDTF7SUY.js";
11
+ } from "./chunk-ABLZ7XRZ.js";
12
12
  import {
13
13
  CliError,
14
14
  DEFAULT_API_BASE,
@@ -41,7 +41,7 @@ import {
41
41
  say,
42
42
  scanCacheFile,
43
43
  selectOne
44
- } from "./chunk-ZIUYW2HQ.js";
44
+ } from "./chunk-7XACBYL7.js";
45
45
 
46
46
  // src/index.ts
47
47
  import { Command } from "commander";
@@ -143,18 +143,22 @@ function buildUpArgs(p) {
143
143
  "-d",
144
144
  p.zone,
145
145
  ...p.proto === "https" ? ["--proto", "https"] : [],
146
+ "--keep-alive",
146
147
  "-f",
147
148
  "-y"
148
149
  ];
149
150
  }
150
151
  const spec = formatTunnelSpec({ subdomain: p.subdomain, port: p.port, host: p.host });
152
+ const proto = p.protocol ?? "http2";
151
153
  return [
152
154
  "up",
153
155
  spec,
154
156
  "-d",
155
157
  p.zone,
156
158
  ...p.proto === "https" ? ["--proto", "https"] : [],
157
- ...p.protocol ? ["--protocol", p.protocol] : [],
159
+ "--protocol",
160
+ proto,
161
+ "--keep-alive",
158
162
  "-f",
159
163
  "-y"
160
164
  ];
@@ -204,6 +208,7 @@ function buildUnit(d) {
204
208
  `Description=cloudtunnel ${d.fqdn} (Cloudflare Tunnel)`,
205
209
  "After=network-online.target",
206
210
  "Wants=network-online.target",
211
+ "StartLimitIntervalSec=0",
207
212
  "",
208
213
  "[Service]",
209
214
  "Type=simple",
@@ -211,7 +216,7 @@ function buildUnit(d) {
211
216
  `Environment=HOME=${d.home}`,
212
217
  `Environment=PATH=${nodeBin}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
213
218
  `ExecStart=${d.nodePath} ${d.scriptPath} ${d.argv.join(" ")}`,
214
- "Restart=on-failure",
219
+ "Restart=always",
215
220
  "RestartSec=5",
216
221
  "",
217
222
  "[Install]",
@@ -297,7 +302,7 @@ var xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&
297
302
  function buildPlist(d) {
298
303
  const args = [d.nodePath, d.scriptPath, ...d.argv].map((a) => ` <string>${xml(a)}</string>`).join("\n");
299
304
  const nodeBin = dirname2(d.nodePath);
300
- const path = `${nodeBin}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`;
305
+ const path = `${nodeBin}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`;
301
306
  return [
302
307
  '<?xml version="1.0" encoding="UTF-8"?>',
303
308
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -381,7 +386,7 @@ function buildTaskXml(d) {
381
386
  " <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>",
382
387
  " <StartWhenAvailable>true</StartWhenAvailable>",
383
388
  " <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>",
384
- " <RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>",
389
+ " <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>",
385
390
  " <Enabled>true</Enabled>",
386
391
  " </Settings>",
387
392
  ' <Actions Context="Author">',
@@ -937,6 +942,7 @@ function extractTgz(bytes) {
937
942
 
938
943
  // src/core/up-runner.ts
939
944
  import { join as join7 } from "path";
945
+ import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
940
946
  import * as clack2 from "@clack/prompts";
941
947
 
942
948
  // src/connector/process.ts
@@ -1063,7 +1069,7 @@ async function reconcile() {
1063
1069
  // src/connector/process.ts
1064
1070
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1065
1071
  function startConnector(opts) {
1066
- const args = ["tunnel", "run"];
1072
+ const args = ["tunnel", "run", "--metrics", "127.0.0.1:0"];
1067
1073
  if (opts.protocol) args.push("--protocol", opts.protocol);
1068
1074
  const env = { ...process.env, TUNNEL_TOKEN: opts.token };
1069
1075
  const fd = openSync(opts.logFile, "a", 384);
@@ -1171,6 +1177,34 @@ async function waitHealthy(cf, tunnelId, opts = {}) {
1171
1177
  }
1172
1178
  return opts.signal?.aborted ? "dead" : "provisioning";
1173
1179
  }
1180
+ function extractMetricsPort(log) {
1181
+ const matches = [...log.matchAll(/Starting metrics server on 127\.0\.0\.1:(\d+)\/metrics/g)];
1182
+ if (!matches.length) return void 0;
1183
+ const last = matches[matches.length - 1];
1184
+ const port = Number(last?.[1]);
1185
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
1186
+ }
1187
+ async function checkLocalReady(port, opts = {}) {
1188
+ const controller = new AbortController();
1189
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 2e3);
1190
+ try {
1191
+ const res = await fetch(`http://127.0.0.1:${port}/ready`, { signal: controller.signal });
1192
+ if (!res.ok) return { healthy: false, readyConnections: 0, status: res.status };
1193
+ const data = await res.json().catch(() => ({}));
1194
+ const readyConnections = Number(data.readyConnections) || 0;
1195
+ return { healthy: readyConnections > 0, readyConnections, status: res.status };
1196
+ } catch {
1197
+ return { healthy: false, readyConnections: 0 };
1198
+ } finally {
1199
+ clearTimeout(timer);
1200
+ }
1201
+ }
1202
+ async function probeEntryState(entry, gone) {
1203
+ if (gone || entry.state !== "running" || !entry.pid) return "down";
1204
+ if (!entry.metricsPort) return "up";
1205
+ const ready = await checkLocalReady(entry.metricsPort, { timeoutMs: 300 });
1206
+ return ready.healthy ? "up" : "reconnecting";
1207
+ }
1174
1208
 
1175
1209
  // src/core/orchestrator-create.ts
1176
1210
  import { randomInt as randomInt2 } from "crypto";
@@ -1432,8 +1466,8 @@ async function resolveRemoteTarget(cf, target) {
1432
1466
  }
1433
1467
  const tunnel = matches[0];
1434
1468
  if (!tunnel) return null;
1435
- const { listCargoCnames } = await import("./dns-2F5SYQNX.js");
1436
- const { listZones: listZones3 } = await import("./zones-PCUEPXHD.js");
1469
+ const { listCargoCnames } = await import("./dns-DKPXBRCH.js");
1470
+ const { listZones: listZones3 } = await import("./zones-XNCXYCB4.js");
1437
1471
  for (const zone of await listZones3(cf.token)) {
1438
1472
  const rec = (await listCargoCnames(cf.token, zone.id)).find((r) => tunnelIdFromCname2(r.content) === tunnel.id);
1439
1473
  if (rec) return { tunnel, fqdn: rec.name };
@@ -1454,24 +1488,25 @@ async function removeTunnelById(cf, tunnel, opts = {}) {
1454
1488
  async function listAll(cf, opts = {}) {
1455
1489
  const entries = await reconcile();
1456
1490
  const tunnels = new Map((await listTunnels(cf)).map((t) => [t.id, t]));
1457
- const rows = entries.map((e) => {
1491
+ const rows = await Promise.all(entries.map(async (e) => {
1458
1492
  const fqdn = entryFqdn(e);
1459
1493
  const gone = e.tunnelId ? !tunnels.has(e.tunnelId) : false;
1460
1494
  const svc = serviceState(fqdn);
1495
+ const state4 = await probeEntryState(e, gone);
1461
1496
  return {
1462
1497
  num: e.index ? String(e.index) : "-",
1463
1498
  url: `https://${fqdn}`,
1464
1499
  target: serviceUrl(e.proto, e.host ?? "localhost", e.port),
1465
1500
  protocol: e.protocol ?? "auto",
1466
- state: !gone && e.state === "running" ? "up" : "down",
1501
+ state: state4,
1467
1502
  service: svc === "none" ? "-" : svc,
1468
1503
  pid: e.state === "running" && e.pid ? String(e.pid) : "-",
1469
1504
  managed: true
1470
1505
  };
1471
- });
1506
+ }));
1472
1507
  if (opts.all) {
1473
- const { listCargoCnames } = await import("./dns-2F5SYQNX.js");
1474
- const { listZones: listZones3 } = await import("./zones-PCUEPXHD.js");
1508
+ const { listCargoCnames } = await import("./dns-DKPXBRCH.js");
1509
+ const { listZones: listZones3 } = await import("./zones-XNCXYCB4.js");
1475
1510
  const tracked = new Set(entries.map(entryFqdn));
1476
1511
  const unmanaged = [];
1477
1512
  for (const zone of await listZones3(cf.token)) {
@@ -1492,9 +1527,141 @@ async function listAll(cf, opts = {}) {
1492
1527
  return rows;
1493
1528
  }
1494
1529
 
1530
+ // src/core/watchdog.ts
1531
+ import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
1532
+ var activeWatchdogs = /* @__PURE__ */ new Map();
1533
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1534
+ function resolveMetricsPort(logFile, fallback) {
1535
+ if (existsSync6(logFile)) {
1536
+ try {
1537
+ const port = extractMetricsPort(readFileSync5(logFile, "utf8"));
1538
+ if (port) return port;
1539
+ } catch {
1540
+ }
1541
+ }
1542
+ return fallback;
1543
+ }
1544
+ async function verifyRemoteTunnel(cf, tunnelId) {
1545
+ try {
1546
+ await getTunnel(cf, tunnelId);
1547
+ return true;
1548
+ } catch (err) {
1549
+ if (err instanceof CliError && err.status === 404) return false;
1550
+ return true;
1551
+ }
1552
+ }
1553
+ async function tier1Restart(ctx, state4) {
1554
+ state4.lastRestartAt = Date.now();
1555
+ state4.tier1Restarts++;
1556
+ say.warn(`[watchdog] Health check failed for ${ctx.fqdn} -> restarting connector (Tier 1)...`);
1557
+ await stopConnector({ pid: ctx.pid, bootId: currentBootId() });
1558
+ const conn = startConnector({
1559
+ bin: ctx.bin,
1560
+ token: ctx.token,
1561
+ detach: false,
1562
+ logFile: ctx.logFile,
1563
+ protocol: ctx.protocol,
1564
+ onExit: () => {
1565
+ void onConnectorExited(ctx.fqdn);
1566
+ }
1567
+ });
1568
+ ctx.pid = conn.pid;
1569
+ await sleep3(1e3);
1570
+ ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1571
+ await patchEntry(ctx.fqdn, { pid: conn.pid, metricsPort: ctx.metricsPort, bootId: currentBootId() });
1572
+ state4.consecutiveFailures = 0;
1573
+ }
1574
+ async function tier2Reprovision(ctx, state4) {
1575
+ state4.lastRestartAt = Date.now();
1576
+ state4.tier2Restarts++;
1577
+ say.warn(`[watchdog] Tunnel missing or unrecoverable for ${ctx.fqdn} -> re-provisioning (Tier 2)...`);
1578
+ await stopConnector({ pid: ctx.pid, bootId: currentBootId() });
1579
+ const result = await createTunnelSubdomain(ctx.cf, { ...ctx.item, force: true, yes: true });
1580
+ ctx.tunnelId = result.tunnelId;
1581
+ ctx.token = result.token;
1582
+ const conn = startConnector({
1583
+ bin: ctx.bin,
1584
+ token: ctx.token,
1585
+ detach: false,
1586
+ logFile: ctx.logFile,
1587
+ protocol: ctx.protocol,
1588
+ onExit: () => {
1589
+ void onConnectorExited(ctx.fqdn);
1590
+ }
1591
+ });
1592
+ ctx.pid = conn.pid;
1593
+ await sleep3(1e3);
1594
+ ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1595
+ await patchEntry(ctx.fqdn, { pid: conn.pid, metricsPort: ctx.metricsPort, bootId: currentBootId(), tunnelId: ctx.tunnelId });
1596
+ state4.consecutiveFailures = 0;
1597
+ state4.tier1Restarts = 0;
1598
+ }
1599
+ async function onConnectorExited(fqdn) {
1600
+ const item = activeWatchdogs.get(fqdn);
1601
+ if (!item) return;
1602
+ const { ctx, state: state4 } = item;
1603
+ if (Date.now() - state4.lastRestartAt < 5e3) return;
1604
+ await tier1Restart(ctx, state4);
1605
+ }
1606
+ function startTunnelWatchdog(ctx, opts = {}) {
1607
+ stopTunnelWatchdog(ctx.fqdn);
1608
+ const intervalMs = opts.intervalMs ?? 15e3;
1609
+ const cooldownMs = opts.cooldownMs ?? 5e3;
1610
+ const state4 = { consecutiveFailures: 0, tier1Restarts: 0, tier2Restarts: 0, lastRestartAt: 0 };
1611
+ const check = async () => {
1612
+ if (!ctx.metricsPort) ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1613
+ let healthy = false;
1614
+ if (ctx.metricsPort) {
1615
+ const res = await checkLocalReady(ctx.metricsPort);
1616
+ healthy = res.healthy;
1617
+ }
1618
+ if (healthy) {
1619
+ state4.consecutiveFailures = 0;
1620
+ return;
1621
+ }
1622
+ state4.consecutiveFailures++;
1623
+ if (state4.consecutiveFailures >= 3 && Date.now() - state4.lastRestartAt >= cooldownMs) {
1624
+ const remoteExists = await verifyRemoteTunnel(ctx.cf, ctx.tunnelId);
1625
+ if (!remoteExists || state4.tier1Restarts >= 3) {
1626
+ await tier2Reprovision(ctx, state4);
1627
+ } else {
1628
+ await tier1Restart(ctx, state4);
1629
+ }
1630
+ }
1631
+ };
1632
+ const timer = setInterval(() => {
1633
+ void check();
1634
+ }, intervalMs);
1635
+ if (typeof timer.unref === "function") timer.unref();
1636
+ activeWatchdogs.set(ctx.fqdn, { timer, ctx, state: state4 });
1637
+ }
1638
+ function stopTunnelWatchdog(fqdn) {
1639
+ const item = activeWatchdogs.get(fqdn);
1640
+ if (item) {
1641
+ clearInterval(item.timer);
1642
+ activeWatchdogs.delete(fqdn);
1643
+ }
1644
+ }
1645
+ function stopAllWatchdogs() {
1646
+ for (const [fqdn] of activeWatchdogs) stopTunnelWatchdog(fqdn);
1647
+ }
1648
+
1495
1649
  // src/core/up-runner.ts
1496
- function logFileFor(subdomain) {
1497
- return join7(logDir, `${subdomain === "@" ? "root" : subdomain}.log`);
1650
+ var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
1651
+ function logFileFor(fqdn) {
1652
+ return join7(logDir, `${serviceSlug(fqdn)}.log`);
1653
+ }
1654
+ async function createWithBootRetry(cf, item, keepAlive) {
1655
+ const deadline = Date.now() + (keepAlive ? 3e4 : 0);
1656
+ for (let attempt = 0; ; attempt++) {
1657
+ try {
1658
+ return await createTunnelSubdomain(cf, item);
1659
+ } catch (err) {
1660
+ if (!keepAlive || Date.now() >= deadline) throw err;
1661
+ say.debug(`[boot-retry] Waiting for network to create ${item.name ?? "tunnel"}...`);
1662
+ await sleep4(Math.min(2 ** attempt * 1e3, 5e3));
1663
+ }
1664
+ }
1498
1665
  }
1499
1666
  async function startTunnels(cf, bin, items, opts = {}) {
1500
1667
  const started = [];
@@ -1502,14 +1669,19 @@ async function startTunnels(cf, bin, items, opts = {}) {
1502
1669
  const teardownAll = async (code) => {
1503
1670
  if (tornDown) return;
1504
1671
  tornDown = true;
1672
+ stopAllWatchdogs();
1505
1673
  try {
1506
1674
  for (const s of started) {
1507
- try {
1508
- await removeTunnelSubdomain(cf, s.fqdn, { force: true, quiet: true });
1509
- } catch {
1675
+ if (opts.keepAlive) {
1676
+ await stopConnector({ pid: s.pid, bootId: currentBootId() });
1677
+ } else {
1678
+ try {
1679
+ await removeTunnelSubdomain(cf, s.fqdn, { force: true, quiet: true });
1680
+ } catch {
1681
+ }
1510
1682
  }
1511
1683
  }
1512
- if (process.stdout.isTTY) clack2.outro(`Stopped \xB7 released ${started.length} subdomain(s)`);
1684
+ if (process.stdout.isTTY && !opts.keepAlive) clack2.outro(`Stopped \xB7 released ${started.length} subdomain(s)`);
1513
1685
  } catch (err) {
1514
1686
  reportError(err);
1515
1687
  } finally {
@@ -1520,9 +1692,9 @@ async function startTunnels(cf, bin, items, opts = {}) {
1520
1692
  spin.start(items.length > 1 ? "Creating tunnels\u2026" : "Creating tunnel\u2026");
1521
1693
  for (const item of items) {
1522
1694
  spin.message(`Creating ${item.name ?? "tunnel"} (:${item.port})\u2026`);
1523
- const result = await createTunnelSubdomain(cf, item);
1695
+ const result = await createWithBootRetry(cf, item, opts.keepAlive);
1524
1696
  const fqdn = result.host.hostname;
1525
- const logFile = logFileFor(result.host.subdomain);
1697
+ const logFile = logFileFor(fqdn);
1526
1698
  const conn = startConnector({
1527
1699
  bin,
1528
1700
  token: result.token,
@@ -1530,13 +1702,21 @@ async function startTunnels(cf, bin, items, opts = {}) {
1530
1702
  logFile,
1531
1703
  protocol: opts.protocol,
1532
1704
  onExit: opts.detach ? void 0 : (code) => {
1533
- if (!tornDown) {
1705
+ if (!tornDown && !opts.keepAlive) {
1534
1706
  say.warn(`Connector for ${fqdn} exited.`);
1535
1707
  void teardownAll(code ?? 1);
1536
1708
  }
1537
1709
  }
1538
1710
  });
1539
- await patchEntry(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile, protocol: opts.protocol });
1711
+ await sleep4(600);
1712
+ let metricsPort;
1713
+ if (existsSync7(logFile)) {
1714
+ try {
1715
+ metricsPort = extractMetricsPort(readFileSync6(logFile, "utf8"));
1716
+ } catch {
1717
+ }
1718
+ }
1719
+ await patchEntry(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile, metricsPort, protocol: opts.protocol });
1540
1720
  started.push({
1541
1721
  fqdn,
1542
1722
  subdomain: result.host.subdomain,
@@ -1544,6 +1724,20 @@ async function startTunnels(cf, bin, items, opts = {}) {
1544
1724
  target: serviceUrl(item.proto, item.host ?? "localhost", item.port),
1545
1725
  pid: conn.pid
1546
1726
  });
1727
+ if (!opts.detach) {
1728
+ startTunnelWatchdog({
1729
+ cf,
1730
+ bin,
1731
+ fqdn,
1732
+ tunnelId: result.tunnelId,
1733
+ token: result.token,
1734
+ logFile,
1735
+ item,
1736
+ pid: conn.pid,
1737
+ metricsPort,
1738
+ protocol: opts.protocol
1739
+ });
1740
+ }
1547
1741
  }
1548
1742
  if (opts.detach) {
1549
1743
  spin.stop(`${started.length} tunnel(s) started in the background`);
@@ -1561,7 +1755,7 @@ async function startTunnels(cf, bin, items, opts = {}) {
1561
1755
  spin.stop(`${started.length} tunnel(s) started`);
1562
1756
  const lines = started.map((s, i) => `${formatRoute(s.fqdn, s.target)}${healths[i] === "healthy" ? "" : dim(` (${healths[i]})`)}`);
1563
1757
  clack2.note(lines.join("\n"), `${live}/${started.length} live`);
1564
- say.dim("Ctrl-C stops and releases them.");
1758
+ say.dim(opts.keepAlive ? "Service running with auto-heal watchdog." : "Ctrl-C stops and releases them.");
1565
1759
  }
1566
1760
 
1567
1761
  // src/core/resolve-domain.ts
@@ -1642,7 +1836,7 @@ async function runUp(specArgs, opts) {
1642
1836
  await registerServices(cf, items, domain, opts.proto, protocol);
1643
1837
  return;
1644
1838
  }
1645
- await startTunnels(cf, bin, items, { detach: opts.detach, protocol });
1839
+ await startTunnels(cf, bin, items, { detach: opts.detach, protocol, keepAlive: opts.keepAlive });
1646
1840
  }
1647
1841
  var SERVICE_UP_TIMEOUT_MS = 2e4;
1648
1842
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -1688,7 +1882,7 @@ async function waitServicesUp(fqdns, timeoutMs) {
1688
1882
  return [...pending];
1689
1883
  }
1690
1884
  function registerUp(program) {
1691
- program.command("up", { isDefault: true }).argument("[specs...]", "tunnels to start: [subdomain:]port[@host] (e.g. api:8080 web:8081@localhost)").description("Start one or more tunnels (also: `cloudtunnel 8080`)").option("-d, --domain <domain>", "domain for the subdomains (prompted from a list if unset)").option("--proto <proto>", "local service protocol: http | https", "http").option("--protocol <proto>", "cloudflared edge transport: auto | http2 | quic (http2 for UDP-hostile networks)").option("--detach", "run the connectors in the background").option("--service", "register each subdomain as a boot service (Linux systemd \xB7 macOS launchd \xB7 Windows Task Scheduler)").option("-f, --force", "replace a non-tunnel DNS record occupying the hostname").option("-y, --yes", "don't prompt; don't ask before replacing an existing record").action((specs, opts) => runUp(specs, opts));
1885
+ program.command("up", { isDefault: true }).argument("[specs...]", "tunnels to start: [subdomain:]port[@host] (e.g. api:8080 web:8081@localhost)").description("Start one or more tunnels (also: `cloudtunnel 8080`)").option("-d, --domain <domain>", "domain for the subdomains (prompted from a list if unset)").option("--proto <proto>", "local service protocol: http | https", "http").option("--protocol <proto>", "cloudflared edge transport: auto | http2 | quic (http2 for UDP-hostile networks)").option("--detach", "run the connectors in the background").option("--keep-alive", "supervised mode with auto-heal watchdog").option("--service", "register each subdomain as a boot service (Linux systemd \xB7 macOS launchd \xB7 Windows Task Scheduler)").option("-f, --force", "replace a non-tunnel DNS record occupying the hostname").option("-y, --yes", "don't prompt; don't ask before replacing an existing record").action((specs, opts) => runUp(specs, opts));
1692
1886
  }
1693
1887
 
1694
1888
  // src/commands/ls.ts
@@ -1763,9 +1957,9 @@ function registerDelete(program) {
1763
1957
  }
1764
1958
 
1765
1959
  // src/commands/logs.ts
1766
- import { closeSync, existsSync as existsSync6, openSync as openSync2, readFileSync as readFileSync5, readSync, statSync, watch } from "fs";
1960
+ import { closeSync, existsSync as existsSync8, openSync as openSync2, readFileSync as readFileSync7, readSync, statSync, watch } from "fs";
1767
1961
  function printTail(file, n) {
1768
- const lines = readFileSync5(file, "utf8").split("\n");
1962
+ const lines = readFileSync7(file, "utf8").split("\n");
1769
1963
  const tail = lines.slice(-n).join("\n");
1770
1964
  process.stdout.write(tail.endsWith("\n") ? tail : `${tail}
1771
1965
  `);
@@ -1797,7 +1991,7 @@ function follow(file, fromPos) {
1797
1991
  function registerLogs(program) {
1798
1992
  program.command("logs").argument("<target>", "subdomain name / hostname / id / #").description("Show the connector log for a subdomain (use -f to follow)").option("-f, --follow", "keep printing new log lines (like tail -f)").option("-n, --lines <n>", "number of lines to show", "50").action((name, opts) => {
1799
1993
  const { fqdn, entry } = resolveTarget(name);
1800
- if (!entry?.logFile || !existsSync6(entry.logFile)) {
1994
+ if (!entry?.logFile || !existsSync8(entry.logFile)) {
1801
1995
  throw new CliError(`No logs for ${fqdn} yet.`, { hint: "start it with `cloudtunnel up` or `cloudtunnel run`" });
1802
1996
  }
1803
1997
  const n = Math.max(1, Number(opts.lines) || 50);