@iamken/cloudtunnel 0.9.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
@@ -69,6 +69,7 @@ Run `cloudtunnel` with no arguments and it guides you (port → subdomain → do
69
69
  | `cloudtunnel ls` · `ps` | List tunnels — `# · URL · TARGET · PROTOCOL · STATE · SERVICE · PID`. `--all` scans the whole account and numbers unmanaged tunnels too. |
70
70
  | `cloudtunnel delete <target…>` | Release tunnel(s) — stop connector + delete tunnel + DNS + any boot service. `--all`, `--dry-run`, `-f`. |
71
71
  | `cloudtunnel logs <target>` | Show a connector's log. `-f` to follow, `-n` for line count. |
72
+ | `cloudtunnel relay [sub]` | Expose a locked Cloudflare-API reverse proxy via a tunnel — lets a client behind a CF-API block manage tunnels through this host. `-d`, `--detach`, `--service`. |
72
73
 
73
74
  > A **`<target>`** is a `#` number, a subdomain name, a full hostname/URL, or a tunnel-id prefix. Tunnels **not tracked locally** (created elsewhere, or leaked by a failed run) work too: use the `#` a `ls --all` scan gave them, or their id — and pair with `-f` when they aren't cloudtunnel-managed.
74
75
 
@@ -102,6 +103,90 @@ alike, and each tunnel's transport shows in the `ls`/`ps` **PROTOCOL** column.
102
103
 
103
104
  ---
104
105
 
106
+ ## 🛰️ Blocked Cloudflare API? Relay it (`relay`)
107
+
108
+ Some networks block **`api.cloudflare.com`** (the management API cloudtunnel calls
109
+ to create tunnels + DNS) while leaving the **data plane** (`*.your-domain.com` via
110
+ the Cloudflare edge) reachable. Run a **relay** on a host that _can_ reach the API,
111
+ then point the blocked client's API base at it — every `login`/`up`/`ls`/`delete`
112
+ rides the data-plane tunnel instead of hitting the API directly.
113
+
114
+ ```
115
+ CLIENT (api.cloudflare.com blocked) RELAY (api.cloudflare.com OK)
116
+ CLOUDTUNNEL_API_BASE=https://cfapi.you.com/client/v4
117
+ │ data-plane tunnel — NOT blocked
118
+
119
+ cfapi.you.com ─(CF edge)─► relay reverse-proxy ─► https://api.cloudflare.com
120
+ ```
121
+
122
+ **On the relay host** — one command starts a locked, secret-gated reverse proxy on
123
+ loopback and exposes it through a normal tunnel:
124
+
125
+ ```bash
126
+ cloudtunnel relay cfapi -d you.com # → https://cfapi.you.com + prints a shared secret
127
+ cloudtunnel relay cfapi -d you.com --detach # background
128
+ cloudtunnel relay cfapi -d you.com --service # run on boot
129
+ ```
130
+
131
+ **On the blocked client** — set the base + secret, then use cloudtunnel normally:
132
+
133
+ ```bash
134
+ export CLOUDTUNNEL_API_BASE=https://cfapi.you.com/client/v4
135
+ export CLOUDTUNNEL_RELAY_SECRET=<secret-from-the-relay>
136
+ printf %s "$CF_TOKEN" | cloudtunnel login --token-stdin # mint the token on an unblocked host
137
+ cloudtunnel up web:8080 -d you.com # now manages tunnels through the relay
138
+ ```
139
+
140
+ Or persist it instead of env vars:
141
+ `cloudtunnel login --api-base https://cfapi.you.com/client/v4 --relay-secret-stdin`
142
+ (pipe the secret on stdin). `cloudtunnel login --status` shows the active **Base** and
143
+ whether a **Relay secret** is set (never the value).
144
+
145
+ The proxy forwards to **`api.cloudflare.com` only** (no open-proxy / SSRF) and
146
+ returns `403` to any request missing the shared secret.
147
+
148
+ > 🔒 **Trust model.** The relay sees the client's Cloudflare **token in plaintext**
149
+ > (it terminates the tunnel) — only relay through a host **you control**, and use a
150
+ > least-privilege token (the four scopes under [Authentication](#-authentication)).
151
+ > The token-mint page (`dash.cloudflare.com`) is usually blocked on the client too —
152
+ > create the token on the relay/any unblocked host and paste it via `--token-stdin`.
153
+
154
+ **Precedence** — API base: `CLOUDTUNNEL_API_BASE` env → `config.apiBase` → default
155
+ `api.cloudflare.com`. Relay secret: `CLOUDTUNNEL_RELAY_SECRET` env → `config.relaySecret`.
156
+
157
+ > ℹ️ The secret is printed only to an interactive terminal (never to a logfile or
158
+ > journald). A **non-interactive** `relay --detach`/`--service` first run won't show
159
+ > the auto-generated secret — set `CLOUDTUNNEL_RELAY_SECRET` yourself in scripts, or
160
+ > read it back from `config.json`.
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
+ ---
189
+
105
190
  ## 🔁 Run on boot (`--service`)
106
191
 
107
192
  Add **`--service`** to register each subdomain as a native OS service so it comes
@@ -3,7 +3,7 @@ import {
3
3
  CliError,
4
4
  cfPaginate,
5
5
  cfRequest
6
- } from "./chunk-3KBLIPKG.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-LG5ST75Z.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-3KBLIPKG.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-VR5RHOTJ.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
 
@@ -79,8 +132,42 @@ function getCredentials() {
79
132
  return { apiToken, accountId, defaultZone: config.defaultZone };
80
133
  }
81
134
 
135
+ // src/config/api-base.ts
136
+ var DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4";
137
+ function getApiBase() {
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;
145
+ }
146
+ function isHttpUrl(s) {
147
+ try {
148
+ const u = new URL(s);
149
+ return u.protocol === "http:" || u.protocol === "https:";
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // src/config/relay-secret.ts
156
+ import { randomBytes } from "crypto";
157
+ var RELAY_SECRET_HEADER = "X-CT-Relay-Secret";
158
+ function getRelaySecret() {
159
+ return process.env.CLOUDTUNNEL_RELAY_SECRET ?? loadConfig().relaySecret;
160
+ }
161
+ function ensureRelaySecret() {
162
+ const existing = getRelaySecret();
163
+ if (existing) return existing;
164
+ const secret = randomBytes(24).toString("base64url");
165
+ const prev = loadConfig();
166
+ saveConfig({ ...prev, relaySecret: secret });
167
+ return secret;
168
+ }
169
+
82
170
  // src/cloudflare/client.ts
83
- var API_BASE = "https://api.cloudflare.com/client/v4";
84
171
  var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
85
172
  var MAX_ATTEMPTS = 4;
86
173
  function resolveCf() {
@@ -94,17 +181,30 @@ function resolveCf() {
94
181
  }
95
182
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
96
183
  async function cfRequest(token, method, path, body) {
184
+ const base = getApiBase();
185
+ const viaRelay = base !== DEFAULT_API_BASE;
186
+ const secret = getRelaySecret();
187
+ const headers = {
188
+ Authorization: `Bearer ${token}`,
189
+ "Content-Type": "application/json"
190
+ };
191
+ if (secret && viaRelay) headers[RELAY_SECRET_HEADER] = secret;
97
192
  for (let attempt = 0; ; attempt++) {
98
193
  let res;
99
194
  try {
100
- res = await fetch(`${API_BASE}${path}`, {
195
+ res = await fetch(`${base}${path}`, {
101
196
  method,
102
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
197
+ headers,
103
198
  body: body === void 0 ? void 0 : JSON.stringify(body)
104
199
  });
105
- } catch {
106
- 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
+ });
107
206
  }
207
+ say.debug(`[cf] ${method} ${base}${path} -> ${res.status}${viaRelay ? " (relay)" : ""}`);
108
208
  if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {
109
209
  const retryAfter = Number(res.headers.get("retry-after")) || 0;
110
210
  await sleep(retryAfter > 0 ? retryAfter * 1e3 : 2 ** attempt * 500);
@@ -112,6 +212,12 @@ async function cfRequest(token, method, path, body) {
112
212
  }
113
213
  const env = await res.json().catch(() => ({}));
114
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
+ }
115
221
  const msg = env.errors?.[0]?.message ?? `HTTP ${res.status}`;
116
222
  throw new CliError(`Cloudflare API error: ${msg}`, { status: res.status });
117
223
  }
@@ -136,6 +242,7 @@ async function cfPaginate(token, basePath) {
136
242
  export {
137
243
  __export,
138
244
  CliError,
245
+ fetchErrorReason,
139
246
  reportError,
140
247
  configFile,
141
248
  registryFile,
@@ -144,11 +251,25 @@ export {
144
251
  binDir,
145
252
  logDir,
146
253
  ensureDirs,
254
+ note,
255
+ confirm,
256
+ redactToken,
257
+ say,
258
+ dim,
259
+ formatRoute,
260
+ printTable,
261
+ selectOne,
147
262
  loadConfig,
148
263
  saveConfig,
149
264
  getCredentials,
265
+ DEFAULT_API_BASE,
266
+ getApiBase,
267
+ isHttpUrl,
268
+ RELAY_SECRET_HEADER,
269
+ getRelaySecret,
270
+ ensureRelaySecret,
150
271
  resolveCf,
151
272
  cfRequest,
152
273
  cfPaginate
153
274
  };
154
- //# sourceMappingURL=chunk-3KBLIPKG.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-LG5ST75Z.js";
11
- import "./chunk-3KBLIPKG.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-C7WXQOED.js.map
21
+ //# sourceMappingURL=dns-2F5SYQNX.js.map