@iamken/cloudtunnel 0.10.0 → 0.10.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.
package/README.md CHANGED
@@ -159,6 +159,32 @@ returns `403` to any request missing the shared secret.
159
159
  > the auto-generated secret — set `CLOUDTUNNEL_RELAY_SECRET` yourself in scripts, or
160
160
  > read it back from `config.json`.
161
161
 
162
+ **Debug / confirm traffic actually goes through the relay:**
163
+
164
+ ```bash
165
+ # CLIENT: trace every CF API call (method · URL · status · relay|direct) to stderr
166
+ CLOUDTUNNEL_DEBUG=1 cloudtunnel ls
167
+ # [cf] GET https://cfapi.you.com/client/v4/accounts?... -> 200 (relay)
168
+ ```
169
+
170
+ The **relay** process logs each request it forwards (method · path · status only —
171
+ never headers): `[relay] 200 GET /client/v4/accounts`. A rejected call is tagged
172
+ with the reason (`secret`, `ssrf`, `bad-form`, `upstream-error`). A relay rejection
173
+ now surfaces as `Relay rejected the request (403): …` on the client instead of a
174
+ misleading "token" error.
175
+
176
+ **Calling the relay directly** (curl / your own client) — send the CF token as usual
177
+ plus the relay secret header:
178
+
179
+ ```bash
180
+ curl https://cfapi.you.com/client/v4/accounts \
181
+ -H "Authorization: Bearer $CF_TOKEN" \
182
+ -H "X-CT-Relay-Secret: <secret>"
183
+ ```
184
+
185
+ The relay validates `X-CT-Relay-Secret`, strips it, and forwards everything else to
186
+ `https://api.cloudflare.com/client/v4/accounts`. Missing/wrong header → `403`.
187
+
162
188
  ---
163
189
 
164
190
  ## 🔁 Run on boot (`--service`)
@@ -3,7 +3,7 @@ import {
3
3
  CliError,
4
4
  cfPaginate,
5
5
  cfRequest
6
- } from "./chunk-HVBIMS4W.js";
6
+ } from "./chunk-ZIUYW2HQ.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-ZTOVYAS4.js.map
57
+ //# sourceMappingURL=chunk-IDTF7SUY.js.map
@@ -3,7 +3,7 @@ import {
3
3
  CliError,
4
4
  cfPaginate,
5
5
  cfRequest
6
- } from "./chunk-HVBIMS4W.js";
6
+ } from "./chunk-ZIUYW2HQ.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-JBKEAGXV.js.map
27
+ //# sourceMappingURL=chunk-NB3OEY2P.js.map
@@ -21,6 +21,10 @@ var CliError = class extends Error {
21
21
  this.status = opts.status;
22
22
  }
23
23
  };
24
+ function fetchErrorReason(err) {
25
+ const cause = err?.cause;
26
+ return cause?.code ?? cause?.message ?? err?.message ?? "unknown";
27
+ }
24
28
  function reportError(err) {
25
29
  if (err instanceof CliError) {
26
30
  console.error(pc.red(`\u2717 ${err.message}`));
@@ -33,6 +37,55 @@ function reportError(err) {
33
37
  return 1;
34
38
  }
35
39
 
40
+ // src/ui/output.ts
41
+ import pc2 from "picocolors";
42
+ import Table from "cli-table3";
43
+ import { cancel, confirm as clackConfirm, intro, isCancel, note, outro, select, spinner } from "@clack/prompts";
44
+ async function confirm(message) {
45
+ const answer = await clackConfirm({ message });
46
+ return !isCancel(answer) && answer === true;
47
+ }
48
+ function redactToken(token) {
49
+ if (!token) return "";
50
+ const last4 = token.length > 4 ? token.slice(-4) : token;
51
+ return `\u2022\u2022\u2022\u2022${last4}`;
52
+ }
53
+ var say = {
54
+ info: (msg) => console.log(msg),
55
+ ok: (msg) => console.log(pc2.green(`\u2713 ${msg}`)),
56
+ warn: (msg) => console.warn(pc2.yellow(`! ${msg}`)),
57
+ dim: (msg) => console.log(pc2.dim(msg)),
58
+ step: (msg) => console.log(pc2.cyan(`\u2192 ${msg}`)),
59
+ // Opt-in request/diagnostic trace. Gated by CLOUDTUNNEL_DEBUG and written to
60
+ // STDERR so it never mixes into command stdout. Never pass a secret/token here.
61
+ debug: (msg) => {
62
+ if (process.env.CLOUDTUNNEL_DEBUG) console.error(pc2.dim(msg));
63
+ }
64
+ };
65
+ var dim = (s) => pc2.dim(s);
66
+ function formatRoute(host, target) {
67
+ return `${pc2.green(pc2.bold(`https://${host}`))} ${pc2.dim("\u2192")} ${pc2.cyan(target)}`;
68
+ }
69
+ function printTable(head, rows) {
70
+ const table = new Table({
71
+ head: head.map((h) => pc2.bold(h)),
72
+ style: { head: [], border: [] }
73
+ });
74
+ for (const row of rows) table.push(row);
75
+ console.log(table.toString());
76
+ }
77
+ async function selectOne(message, items, label) {
78
+ const value = await select({
79
+ message,
80
+ options: items.map((item, i) => ({ value: String(i), label: label(item) }))
81
+ });
82
+ if (isCancel(value)) {
83
+ cancel("Cancelled.");
84
+ throw new CliError("Cancelled.", { exitCode: 130 });
85
+ }
86
+ return items[Number(value)];
87
+ }
88
+
36
89
  // src/config/store.ts
37
90
  import { chmodSync, readFileSync, writeFileSync } from "fs";
38
91
 
@@ -82,8 +135,13 @@ function getCredentials() {
82
135
  // src/config/api-base.ts
83
136
  var DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4";
84
137
  function getApiBase() {
85
- const raw = process.env.CLOUDTUNNEL_API_BASE?.trim() || loadConfig().apiBase || DEFAULT_API_BASE;
86
- return raw.replace(/\/+$/, "");
138
+ const raw = (process.env.CLOUDTUNNEL_API_BASE?.trim() || loadConfig().apiBase || DEFAULT_API_BASE).replace(/\/+$/, "");
139
+ if (!isHttpUrl(raw)) {
140
+ throw new CliError(`Invalid Cloudflare API base "${raw}".`, {
141
+ hint: "CLOUDTUNNEL_API_BASE must be a full http(s) URL, e.g. https://cfapi.example.com/client/v4"
142
+ });
143
+ }
144
+ return raw;
87
145
  }
88
146
  function isHttpUrl(s) {
89
147
  try {
@@ -124,12 +182,13 @@ function resolveCf() {
124
182
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
125
183
  async function cfRequest(token, method, path, body) {
126
184
  const base = getApiBase();
185
+ const viaRelay = base !== DEFAULT_API_BASE;
127
186
  const secret = getRelaySecret();
128
187
  const headers = {
129
188
  Authorization: `Bearer ${token}`,
130
189
  "Content-Type": "application/json"
131
190
  };
132
- if (secret && base !== DEFAULT_API_BASE) headers[RELAY_SECRET_HEADER] = secret;
191
+ if (secret && viaRelay) headers[RELAY_SECRET_HEADER] = secret;
133
192
  for (let attempt = 0; ; attempt++) {
134
193
  let res;
135
194
  try {
@@ -138,9 +197,14 @@ async function cfRequest(token, method, path, body) {
138
197
  headers,
139
198
  body: body === void 0 ? void 0 : JSON.stringify(body)
140
199
  });
141
- } catch {
142
- throw new CliError("Could not reach the Cloudflare API (network error).");
200
+ } catch (err) {
201
+ const reason = fetchErrorReason(err);
202
+ say.debug(`[cf] ${method} ${base}${path} -> network error: ${reason}${viaRelay ? " (relay)" : ""}`);
203
+ throw new CliError(`Could not reach the Cloudflare API (${reason})${viaRelay ? ` via relay ${base}` : ""}.`, {
204
+ hint: viaRelay ? "is the relay tunnel up and the base URL correct? run with CLOUDTUNNEL_DEBUG=1" : void 0
205
+ });
143
206
  }
207
+ say.debug(`[cf] ${method} ${base}${path} -> ${res.status}${viaRelay ? " (relay)" : ""}`);
144
208
  if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {
145
209
  const retryAfter = Number(res.headers.get("retry-after")) || 0;
146
210
  await sleep(retryAfter > 0 ? retryAfter * 1e3 : 2 ** attempt * 500);
@@ -148,6 +212,12 @@ async function cfRequest(token, method, path, body) {
148
212
  }
149
213
  const env = await res.json().catch(() => ({}));
150
214
  if (!res.ok || !env.success) {
215
+ if (viaRelay && env.error && !env.errors) {
216
+ throw new CliError(`Relay rejected the request (${res.status}): ${env.error}.`, {
217
+ status: res.status,
218
+ hint: res.status === 403 ? "does CLOUDTUNNEL_RELAY_SECRET match the relay's secret?" : `relay base: ${base}`
219
+ });
220
+ }
151
221
  const msg = env.errors?.[0]?.message ?? `HTTP ${res.status}`;
152
222
  throw new CliError(`Cloudflare API error: ${msg}`, { status: res.status });
153
223
  }
@@ -172,6 +242,7 @@ async function cfPaginate(token, basePath) {
172
242
  export {
173
243
  __export,
174
244
  CliError,
245
+ fetchErrorReason,
175
246
  reportError,
176
247
  configFile,
177
248
  registryFile,
@@ -180,6 +251,14 @@ export {
180
251
  binDir,
181
252
  logDir,
182
253
  ensureDirs,
254
+ note,
255
+ confirm,
256
+ redactToken,
257
+ say,
258
+ dim,
259
+ formatRoute,
260
+ printTable,
261
+ selectOne,
183
262
  loadConfig,
184
263
  saveConfig,
185
264
  getCredentials,
@@ -193,4 +272,4 @@ export {
193
272
  cfRequest,
194
273
  cfPaginate
195
274
  };
196
- //# sourceMappingURL=chunk-HVBIMS4W.js.map
275
+ //# sourceMappingURL=chunk-ZIUYW2HQ.js.map
@@ -0,0 +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"]}
@@ -7,8 +7,8 @@ import {
7
7
  findCname,
8
8
  isManagedDns,
9
9
  listCargoCnames
10
- } from "./chunk-ZTOVYAS4.js";
11
- import "./chunk-HVBIMS4W.js";
10
+ } from "./chunk-IDTF7SUY.js";
11
+ import "./chunk-ZIUYW2HQ.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-43EGJW7E.js.map
21
+ //# sourceMappingURL=dns-2F5SYQNX.js.map
package/dist/index.js CHANGED
@@ -2,13 +2,13 @@
2
2
  import {
3
3
  listZones,
4
4
  resolveZone
5
- } from "./chunk-JBKEAGXV.js";
5
+ } from "./chunk-NB3OEY2P.js";
6
6
  import {
7
7
  createCname,
8
8
  deleteDnsRecord,
9
9
  findCname,
10
10
  isManagedDns
11
- } from "./chunk-ZTOVYAS4.js";
11
+ } from "./chunk-IDTF7SUY.js";
12
12
  import {
13
13
  CliError,
14
14
  DEFAULT_API_BASE,
@@ -18,74 +18,39 @@ import {
18
18
  cfPaginate,
19
19
  cfRequest,
20
20
  configFile,
21
+ confirm,
22
+ dim,
21
23
  ensureDirs,
22
24
  ensureRelaySecret,
25
+ fetchErrorReason,
26
+ formatRoute,
23
27
  getApiBase,
24
28
  getCredentials,
25
29
  getRelaySecret,
26
30
  isHttpUrl,
27
31
  loadConfig,
28
32
  logDir,
33
+ note,
34
+ printTable,
29
35
  profilesFile,
36
+ redactToken,
30
37
  registryFile,
31
38
  reportError,
32
39
  resolveCf,
33
40
  saveConfig,
34
- scanCacheFile
35
- } from "./chunk-HVBIMS4W.js";
41
+ say,
42
+ scanCacheFile,
43
+ selectOne
44
+ } from "./chunk-ZIUYW2HQ.js";
36
45
 
37
46
  // src/index.ts
38
47
  import { Command } from "commander";
39
48
  import { createRequire } from "module";
40
- import pc3 from "picocolors";
49
+ import pc2 from "picocolors";
41
50
 
42
51
  // src/config/legacy-migrate.ts
43
52
  import { existsSync as existsSync3, readFileSync, renameSync, writeFileSync as writeFileSync4 } from "fs";
44
53
 
45
- // src/ui/output.ts
46
- import pc from "picocolors";
47
- import Table from "cli-table3";
48
- import { cancel, confirm as clackConfirm, intro, isCancel, note, outro, select, spinner } from "@clack/prompts";
49
- async function confirm(message) {
50
- const answer = await clackConfirm({ message });
51
- return !isCancel(answer) && answer === true;
52
- }
53
- function redactToken(token) {
54
- if (!token) return "";
55
- const last4 = token.length > 4 ? token.slice(-4) : token;
56
- return `\u2022\u2022\u2022\u2022${last4}`;
57
- }
58
- var say = {
59
- info: (msg) => console.log(msg),
60
- ok: (msg) => console.log(pc.green(`\u2713 ${msg}`)),
61
- warn: (msg) => console.warn(pc.yellow(`! ${msg}`)),
62
- dim: (msg) => console.log(pc.dim(msg)),
63
- step: (msg) => console.log(pc.cyan(`\u2192 ${msg}`))
64
- };
65
- var dim = (s) => pc.dim(s);
66
- function formatRoute(host, target) {
67
- return `${pc.green(pc.bold(`https://${host}`))} ${pc.dim("\u2192")} ${pc.cyan(target)}`;
68
- }
69
- function printTable(head, rows) {
70
- const table = new Table({
71
- head: head.map((h) => pc.bold(h)),
72
- style: { head: [], border: [] }
73
- });
74
- for (const row of rows) table.push(row);
75
- console.log(table.toString());
76
- }
77
- async function selectOne(message, items, label4) {
78
- const value = await select({
79
- message,
80
- options: items.map((item, i) => ({ value: String(i), label: label4(item) }))
81
- });
82
- if (isCancel(value)) {
83
- cancel("Cancelled.");
84
- throw new CliError("Cancelled.", { exitCode: 130 });
85
- }
86
- return items[Number(value)];
87
- }
88
-
89
54
  // src/core/service.ts
90
55
  import { join as join5 } from "path";
91
56
 
@@ -591,17 +556,29 @@ function openBrowser(url) {
591
556
  // src/config/resolve-identity.ts
592
557
  async function cfGet(path, token) {
593
558
  const base = getApiBase();
559
+ const viaRelay = base !== DEFAULT_API_BASE;
594
560
  const secret = getRelaySecret();
595
561
  const headers = {
596
562
  Authorization: `Bearer ${token}`,
597
563
  "Content-Type": "application/json"
598
564
  };
599
- if (secret && base !== DEFAULT_API_BASE) headers[RELAY_SECRET_HEADER] = secret;
565
+ if (secret && viaRelay) headers[RELAY_SECRET_HEADER] = secret;
600
566
  let res;
601
567
  try {
602
568
  res = await fetch(`${base}${path}`, { headers });
603
- } catch {
604
- throw new CliError("Could not reach the Cloudflare API (network error).");
569
+ } catch (err) {
570
+ const reason = fetchErrorReason(err);
571
+ say.debug(`[cf] GET ${base}${path} -> network error: ${reason}${viaRelay ? " (relay)" : ""}`);
572
+ throw new CliError(`Could not reach the Cloudflare API (${reason})${viaRelay ? ` via relay ${base}` : ""}.`, {
573
+ hint: viaRelay ? "is the relay tunnel up and the base URL correct? run with CLOUDTUNNEL_DEBUG=1" : void 0
574
+ });
575
+ }
576
+ say.debug(`[cf] GET ${base}${path} -> ${res.status}${viaRelay ? " (relay)" : ""}`);
577
+ const body = await res.json().catch(() => ({}));
578
+ if (viaRelay && !res.ok && body.error && !body.errors) {
579
+ throw new CliError(`Relay rejected the request (${res.status}): ${body.error}.`, {
580
+ hint: res.status === 403 ? "does CLOUDTUNNEL_RELAY_SECRET match the relay's secret?" : `relay base: ${base}`
581
+ });
605
582
  }
606
583
  if (res.status === 401) {
607
584
  throw new CliError("Cloudflare rejected the token (invalid or expired).", {
@@ -613,7 +590,6 @@ async function cfGet(path, token) {
613
590
  hint: `token needs: ${REQUIRED_SCOPES.join(", ")}`
614
591
  });
615
592
  }
616
- const body = await res.json().catch(() => ({}));
617
593
  if (!res.ok || !body.success) {
618
594
  throw new CliError(`Cloudflare API error (${res.status}) on ${path}.`);
619
595
  }
@@ -1359,8 +1335,8 @@ async function resolveRemoteTarget(cf, target) {
1359
1335
  }
1360
1336
  const tunnel = matches[0];
1361
1337
  if (!tunnel) return null;
1362
- const { listCargoCnames } = await import("./dns-43EGJW7E.js");
1363
- const { listZones: listZones3 } = await import("./zones-QZPJFIDD.js");
1338
+ const { listCargoCnames } = await import("./dns-2F5SYQNX.js");
1339
+ const { listZones: listZones3 } = await import("./zones-PCUEPXHD.js");
1364
1340
  for (const zone of await listZones3(cf.token)) {
1365
1341
  const rec = (await listCargoCnames(cf.token, zone.id)).find((r) => tunnelIdFromCname2(r.content) === tunnel.id);
1366
1342
  if (rec) return { tunnel, fqdn: rec.name };
@@ -1397,8 +1373,8 @@ async function listAll(cf, opts = {}) {
1397
1373
  };
1398
1374
  });
1399
1375
  if (opts.all) {
1400
- const { listCargoCnames } = await import("./dns-43EGJW7E.js");
1401
- const { listZones: listZones3 } = await import("./zones-QZPJFIDD.js");
1376
+ const { listCargoCnames } = await import("./dns-2F5SYQNX.js");
1377
+ const { listZones: listZones3 } = await import("./zones-PCUEPXHD.js");
1402
1378
  const tracked = new Set(entries.map(entryFqdn));
1403
1379
  const unmanaged = [];
1404
1380
  for (const zone of await listZones3(cf.token)) {
@@ -1737,7 +1713,7 @@ function registerLogs(program) {
1737
1713
  import { openSync as openSync3 } from "fs";
1738
1714
  import { spawn as spawn3 } from "child_process";
1739
1715
  import { join as join8 } from "path";
1740
- import pc2 from "picocolors";
1716
+ import pc from "picocolors";
1741
1717
 
1742
1718
  // src/core/api-proxy-server.ts
1743
1719
  import http from "http";
@@ -1762,6 +1738,8 @@ function send(res, code, body) {
1762
1738
  function startProxy(opts) {
1763
1739
  const upstream = opts.upstream ?? DEFAULT_UPSTREAM;
1764
1740
  const upstreamOrigin = new URL(upstream).origin;
1741
+ const log = opts.log ?? (() => {
1742
+ });
1765
1743
  const server = http.createServer((req, res) => {
1766
1744
  handle(req, res).catch(() => {
1767
1745
  if (!res.headersSent) send(res, 500, { error: "relay internal error" });
@@ -1769,19 +1747,24 @@ function startProxy(opts) {
1769
1747
  });
1770
1748
  });
1771
1749
  async function handle(req, res) {
1750
+ const trace = (status, note4) => log(`[relay] ${status} ${req.method} ${req.url ?? "-"}${note4 ? " " + note4 : ""}`);
1772
1751
  if (req.method === "CONNECT" || !req.url || !req.url.startsWith("/")) {
1752
+ trace(400, "bad-form");
1773
1753
  return send(res, 400, { error: "origin-form request required" });
1774
1754
  }
1775
1755
  let target;
1776
1756
  try {
1777
1757
  target = new URL(req.url, upstream);
1778
1758
  } catch {
1759
+ trace(400, "bad-path");
1779
1760
  return send(res, 400, { error: "bad request path" });
1780
1761
  }
1781
1762
  if (target.origin !== upstreamOrigin) {
1763
+ trace(400, "ssrf");
1782
1764
  return send(res, 400, { error: "path escapes upstream" });
1783
1765
  }
1784
1766
  if (req.headers[SECRET_HEADER_LC] !== opts.secret) {
1767
+ trace(403, "secret");
1785
1768
  return send(res, 403, { error: "relay secret missing or invalid" });
1786
1769
  }
1787
1770
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
@@ -1802,8 +1785,10 @@ function startProxy(opts) {
1802
1785
  try {
1803
1786
  up = await fetch(target.href, { method: req.method, headers, body, redirect: "manual" });
1804
1787
  } catch {
1788
+ trace(502, "upstream-error");
1805
1789
  return send(res, 502, { error: "relay upstream unreachable" });
1806
1790
  }
1791
+ trace(up.status);
1807
1792
  const outHeaders = {
1808
1793
  "content-type": up.headers.get("content-type") ?? "application/json"
1809
1794
  };
@@ -1845,14 +1830,14 @@ function relayReadyLines(fqdn, secret, tty) {
1845
1830
  if (!tty) return [`relay ready at ${url}`];
1846
1831
  const base = `${url}/client/v4`;
1847
1832
  return [
1848
- `URL ${pc2.green(url)}`,
1849
- `Secret ${pc2.bold(secret)} ${pc2.dim("(store it \u2014 the client needs it, shown once)")}`,
1833
+ `URL ${pc.green(url)}`,
1834
+ `Secret ${pc.bold(secret)} ${pc.dim("(store it \u2014 the client needs it, shown once)")}`,
1850
1835
  "",
1851
- pc2.bold("On the blocked client:"),
1836
+ pc.bold("On the blocked client:"),
1852
1837
  ` export CLOUDTUNNEL_API_BASE=${base}`,
1853
1838
  ` export CLOUDTUNNEL_RELAY_SECRET=${secret}`,
1854
1839
  ` printf %s "$CF_TOKEN" | cloudtunnel login --token-stdin`,
1855
- pc2.dim(" (mint the CF token on an unblocked host \u2014 dash.cloudflare.com is blocked too)")
1840
+ pc.dim(" (mint the CF token on an unblocked host \u2014 dash.cloudflare.com is blocked too)")
1856
1841
  ];
1857
1842
  }
1858
1843
  function printRelayReady(fqdn, secret, kind) {
@@ -1883,7 +1868,7 @@ async function runRelay(sub, opts) {
1883
1868
  }
1884
1869
  const bin = await ensureCloudflared();
1885
1870
  const secret = ensureRelaySecret();
1886
- const proxy = await startProxy({ secret });
1871
+ const proxy = await startProxy({ secret, log: (line) => say.dim(line) });
1887
1872
  const item = {
1888
1873
  port: proxy.port,
1889
1874
  proto: "http",
@@ -1909,11 +1894,11 @@ function buildProgram() {
1909
1894
  program.addHelpText(
1910
1895
  "before",
1911
1896
  [
1912
- pc3.bold("Quickstart:"),
1913
- ` ${pc3.cyan("cloudtunnel login")} once \u2014 paste a token (or set CLOUDFLARE_API_TOKEN)`,
1914
- ` ${pc3.cyan("cloudtunnel 8080")} your local :8080 goes live at an HTTPS URL`,
1915
- ` ${pc3.cyan("cloudtunnel api:8080")} api.<domain> \u2192 localhost:8080`,
1916
- ` ${pc3.cyan("cloudtunnel ls")} list tunnels ${pc3.dim("\xB7")} ${pc3.cyan("cloudtunnel delete <#>")} remove one`,
1897
+ pc2.bold("Quickstart:"),
1898
+ ` ${pc2.cyan("cloudtunnel login")} once \u2014 paste a token (or set CLOUDFLARE_API_TOKEN)`,
1899
+ ` ${pc2.cyan("cloudtunnel 8080")} your local :8080 goes live at an HTTPS URL`,
1900
+ ` ${pc2.cyan("cloudtunnel api:8080")} api.<domain> \u2192 localhost:8080`,
1901
+ ` ${pc2.cyan("cloudtunnel ls")} list tunnels ${pc2.dim("\xB7")} ${pc2.cyan("cloudtunnel delete <#>")} remove one`,
1917
1902
  ""
1918
1903
  ].join("\n")
1919
1904
  );