@iamken/cloudtunnel 0.10.0 → 0.10.2

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,42 @@ 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
+
188
+ **Behind a proxy?** Node's `fetch` ignores proxies by default, so on a proxied
189
+ network the client fails with `UND_ERR_CONNECT_TIMEOUT` even though curl/Postman
190
+ work. cloudtunnel now routes through a proxy automatically from `HTTPS_PROXY`/
191
+ `HTTP_PROXY` (and, on Linux/GNOME, the system proxy). Set it once if needed:
192
+
193
+ ```bash
194
+ export HTTPS_PROXY=http://your-proxy:port
195
+ CLOUDTUNNEL_DEBUG=1 cloudtunnel ls # logs "[proxy] routing fetch through …"
196
+ ```
197
+
162
198
  ---
163
199
 
164
200
  ## 🔁 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
 
@@ -562,6 +527,36 @@ async function migrateLegacyProfiles() {
562
527
  }
563
528
  }
564
529
 
530
+ // src/config/proxy.ts
531
+ import { execFileSync as execFileSync4 } from "child_process";
532
+ import { ProxyAgent, setGlobalDispatcher } from "undici";
533
+ function proxyFromEnv() {
534
+ return process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? process.env.ALL_PROXY ?? process.env.all_proxy;
535
+ }
536
+ function proxyFromGnome() {
537
+ if (process.platform !== "linux") return void 0;
538
+ try {
539
+ const get = (schema, key) => execFileSync4("gsettings", ["get", schema, key], { encoding: "utf8", timeout: 1500 }).trim().replace(/^'|'$/g, "");
540
+ if (get("org.gnome.system.proxy", "mode") !== "manual") return void 0;
541
+ for (const scheme of ["https", "http"]) {
542
+ const host = get(`org.gnome.system.proxy.${scheme}`, "host");
543
+ const port = Number(get(`org.gnome.system.proxy.${scheme}`, "port"));
544
+ if (host && port) return `http://${host}:${port}`;
545
+ }
546
+ } catch {
547
+ }
548
+ return void 0;
549
+ }
550
+ function configureProxy() {
551
+ const proxy = proxyFromEnv() ?? proxyFromGnome();
552
+ if (!proxy) return;
553
+ try {
554
+ setGlobalDispatcher(new ProxyAgent(proxy));
555
+ say.debug(`[proxy] routing fetch through ${proxy}`);
556
+ } catch {
557
+ }
558
+ }
559
+
565
560
  // src/commands/login.ts
566
561
  import * as clack from "@clack/prompts";
567
562
 
@@ -591,17 +586,29 @@ function openBrowser(url) {
591
586
  // src/config/resolve-identity.ts
592
587
  async function cfGet(path, token) {
593
588
  const base = getApiBase();
589
+ const viaRelay = base !== DEFAULT_API_BASE;
594
590
  const secret = getRelaySecret();
595
591
  const headers = {
596
592
  Authorization: `Bearer ${token}`,
597
593
  "Content-Type": "application/json"
598
594
  };
599
- if (secret && base !== DEFAULT_API_BASE) headers[RELAY_SECRET_HEADER] = secret;
595
+ if (secret && viaRelay) headers[RELAY_SECRET_HEADER] = secret;
600
596
  let res;
601
597
  try {
602
598
  res = await fetch(`${base}${path}`, { headers });
603
- } catch {
604
- throw new CliError("Could not reach the Cloudflare API (network error).");
599
+ } catch (err) {
600
+ const reason = fetchErrorReason(err);
601
+ say.debug(`[cf] GET ${base}${path} -> network error: ${reason}${viaRelay ? " (relay)" : ""}`);
602
+ throw new CliError(`Could not reach the Cloudflare API (${reason})${viaRelay ? ` via relay ${base}` : ""}.`, {
603
+ hint: viaRelay ? "is the relay tunnel up and the base URL correct? run with CLOUDTUNNEL_DEBUG=1" : void 0
604
+ });
605
+ }
606
+ say.debug(`[cf] GET ${base}${path} -> ${res.status}${viaRelay ? " (relay)" : ""}`);
607
+ const body = await res.json().catch(() => ({}));
608
+ if (viaRelay && !res.ok && body.error && !body.errors) {
609
+ throw new CliError(`Relay rejected the request (${res.status}): ${body.error}.`, {
610
+ hint: res.status === 403 ? "does CLOUDTUNNEL_RELAY_SECRET match the relay's secret?" : `relay base: ${base}`
611
+ });
605
612
  }
606
613
  if (res.status === 401) {
607
614
  throw new CliError("Cloudflare rejected the token (invalid or expired).", {
@@ -613,7 +620,6 @@ async function cfGet(path, token) {
613
620
  hint: `token needs: ${REQUIRED_SCOPES.join(", ")}`
614
621
  });
615
622
  }
616
- const body = await res.json().catch(() => ({}));
617
623
  if (!res.ok || !body.success) {
618
624
  throw new CliError(`Cloudflare API error (${res.status}) on ${path}.`);
619
625
  }
@@ -762,7 +768,7 @@ async function ensureAuth() {
762
768
  }
763
769
 
764
770
  // src/connector/binary.ts
765
- import { execFileSync as execFileSync4 } from "child_process";
771
+ import { execFileSync as execFileSync5 } from "child_process";
766
772
  import { createHash } from "crypto";
767
773
  import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync5 } from "fs";
768
774
  import { join as join6 } from "path";
@@ -784,7 +790,7 @@ function resolveAssetKey(platform, arch) {
784
790
  }
785
791
  function binaryWorks(bin) {
786
792
  try {
787
- execFileSync4(bin, ["--version"], { stdio: "ignore" });
793
+ execFileSync5(bin, ["--version"], { stdio: "ignore" });
788
794
  return true;
789
795
  } catch {
790
796
  return false;
@@ -867,7 +873,7 @@ import { join as join7 } from "path";
867
873
  import * as clack2 from "@clack/prompts";
868
874
 
869
875
  // src/connector/process.ts
870
- import { execFileSync as execFileSync5, spawn as spawn2 } from "child_process";
876
+ import { execFileSync as execFileSync6, spawn as spawn2 } from "child_process";
871
877
  import { openSync } from "fs";
872
878
 
873
879
  // src/connector/registry.ts
@@ -1009,7 +1015,7 @@ async function stopConnector(entry) {
1009
1015
  const pid = entry.pid;
1010
1016
  if (process.platform === "win32") {
1011
1017
  try {
1012
- execFileSync5("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
1018
+ execFileSync6("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
1013
1019
  } catch {
1014
1020
  return false;
1015
1021
  }
@@ -1359,8 +1365,8 @@ async function resolveRemoteTarget(cf, target) {
1359
1365
  }
1360
1366
  const tunnel = matches[0];
1361
1367
  if (!tunnel) return null;
1362
- const { listCargoCnames } = await import("./dns-43EGJW7E.js");
1363
- const { listZones: listZones3 } = await import("./zones-QZPJFIDD.js");
1368
+ const { listCargoCnames } = await import("./dns-2F5SYQNX.js");
1369
+ const { listZones: listZones3 } = await import("./zones-PCUEPXHD.js");
1364
1370
  for (const zone of await listZones3(cf.token)) {
1365
1371
  const rec = (await listCargoCnames(cf.token, zone.id)).find((r) => tunnelIdFromCname2(r.content) === tunnel.id);
1366
1372
  if (rec) return { tunnel, fqdn: rec.name };
@@ -1397,8 +1403,8 @@ async function listAll(cf, opts = {}) {
1397
1403
  };
1398
1404
  });
1399
1405
  if (opts.all) {
1400
- const { listCargoCnames } = await import("./dns-43EGJW7E.js");
1401
- const { listZones: listZones3 } = await import("./zones-QZPJFIDD.js");
1406
+ const { listCargoCnames } = await import("./dns-2F5SYQNX.js");
1407
+ const { listZones: listZones3 } = await import("./zones-PCUEPXHD.js");
1402
1408
  const tracked = new Set(entries.map(entryFqdn));
1403
1409
  const unmanaged = [];
1404
1410
  for (const zone of await listZones3(cf.token)) {
@@ -1737,7 +1743,7 @@ function registerLogs(program) {
1737
1743
  import { openSync as openSync3 } from "fs";
1738
1744
  import { spawn as spawn3 } from "child_process";
1739
1745
  import { join as join8 } from "path";
1740
- import pc2 from "picocolors";
1746
+ import pc from "picocolors";
1741
1747
 
1742
1748
  // src/core/api-proxy-server.ts
1743
1749
  import http from "http";
@@ -1762,6 +1768,8 @@ function send(res, code, body) {
1762
1768
  function startProxy(opts) {
1763
1769
  const upstream = opts.upstream ?? DEFAULT_UPSTREAM;
1764
1770
  const upstreamOrigin = new URL(upstream).origin;
1771
+ const log = opts.log ?? (() => {
1772
+ });
1765
1773
  const server = http.createServer((req, res) => {
1766
1774
  handle(req, res).catch(() => {
1767
1775
  if (!res.headersSent) send(res, 500, { error: "relay internal error" });
@@ -1769,19 +1777,24 @@ function startProxy(opts) {
1769
1777
  });
1770
1778
  });
1771
1779
  async function handle(req, res) {
1780
+ const trace = (status, note4) => log(`[relay] ${status} ${req.method} ${req.url ?? "-"}${note4 ? " " + note4 : ""}`);
1772
1781
  if (req.method === "CONNECT" || !req.url || !req.url.startsWith("/")) {
1782
+ trace(400, "bad-form");
1773
1783
  return send(res, 400, { error: "origin-form request required" });
1774
1784
  }
1775
1785
  let target;
1776
1786
  try {
1777
1787
  target = new URL(req.url, upstream);
1778
1788
  } catch {
1789
+ trace(400, "bad-path");
1779
1790
  return send(res, 400, { error: "bad request path" });
1780
1791
  }
1781
1792
  if (target.origin !== upstreamOrigin) {
1793
+ trace(400, "ssrf");
1782
1794
  return send(res, 400, { error: "path escapes upstream" });
1783
1795
  }
1784
1796
  if (req.headers[SECRET_HEADER_LC] !== opts.secret) {
1797
+ trace(403, "secret");
1785
1798
  return send(res, 403, { error: "relay secret missing or invalid" });
1786
1799
  }
1787
1800
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
@@ -1802,8 +1815,10 @@ function startProxy(opts) {
1802
1815
  try {
1803
1816
  up = await fetch(target.href, { method: req.method, headers, body, redirect: "manual" });
1804
1817
  } catch {
1818
+ trace(502, "upstream-error");
1805
1819
  return send(res, 502, { error: "relay upstream unreachable" });
1806
1820
  }
1821
+ trace(up.status);
1807
1822
  const outHeaders = {
1808
1823
  "content-type": up.headers.get("content-type") ?? "application/json"
1809
1824
  };
@@ -1845,14 +1860,14 @@ function relayReadyLines(fqdn, secret, tty) {
1845
1860
  if (!tty) return [`relay ready at ${url}`];
1846
1861
  const base = `${url}/client/v4`;
1847
1862
  return [
1848
- `URL ${pc2.green(url)}`,
1849
- `Secret ${pc2.bold(secret)} ${pc2.dim("(store it \u2014 the client needs it, shown once)")}`,
1863
+ `URL ${pc.green(url)}`,
1864
+ `Secret ${pc.bold(secret)} ${pc.dim("(store it \u2014 the client needs it, shown once)")}`,
1850
1865
  "",
1851
- pc2.bold("On the blocked client:"),
1866
+ pc.bold("On the blocked client:"),
1852
1867
  ` export CLOUDTUNNEL_API_BASE=${base}`,
1853
1868
  ` export CLOUDTUNNEL_RELAY_SECRET=${secret}`,
1854
1869
  ` 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)")
1870
+ pc.dim(" (mint the CF token on an unblocked host \u2014 dash.cloudflare.com is blocked too)")
1856
1871
  ];
1857
1872
  }
1858
1873
  function printRelayReady(fqdn, secret, kind) {
@@ -1883,7 +1898,7 @@ async function runRelay(sub, opts) {
1883
1898
  }
1884
1899
  const bin = await ensureCloudflared();
1885
1900
  const secret = ensureRelaySecret();
1886
- const proxy = await startProxy({ secret });
1901
+ const proxy = await startProxy({ secret, log: (line) => say.dim(line) });
1887
1902
  const item = {
1888
1903
  port: proxy.port,
1889
1904
  proto: "http",
@@ -1909,11 +1924,11 @@ function buildProgram() {
1909
1924
  program.addHelpText(
1910
1925
  "before",
1911
1926
  [
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`,
1927
+ pc2.bold("Quickstart:"),
1928
+ ` ${pc2.cyan("cloudtunnel login")} once \u2014 paste a token (or set CLOUDFLARE_API_TOKEN)`,
1929
+ ` ${pc2.cyan("cloudtunnel 8080")} your local :8080 goes live at an HTTPS URL`,
1930
+ ` ${pc2.cyan("cloudtunnel api:8080")} api.<domain> \u2192 localhost:8080`,
1931
+ ` ${pc2.cyan("cloudtunnel ls")} list tunnels ${pc2.dim("\xB7")} ${pc2.cyan("cloudtunnel delete <#>")} remove one`,
1917
1932
  ""
1918
1933
  ].join("\n")
1919
1934
  );
@@ -1929,6 +1944,7 @@ function shouldMigrate(argv) {
1929
1944
  return !rest.some((a) => infoFlag.has(a));
1930
1945
  }
1931
1946
  async function main() {
1947
+ configureProxy();
1932
1948
  if (shouldMigrate(process.argv)) await migrateLegacyProfiles();
1933
1949
  const program = buildProgram();
1934
1950
  try {