@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 +26 -0
- package/dist/{chunk-ZTOVYAS4.js → chunk-IDTF7SUY.js} +2 -2
- package/dist/{chunk-JBKEAGXV.js → chunk-NB3OEY2P.js} +2 -2
- package/dist/{chunk-HVBIMS4W.js → chunk-ZIUYW2HQ.js} +85 -6
- package/dist/chunk-ZIUYW2HQ.js.map +1 -0
- package/dist/{dns-43EGJW7E.js → dns-2F5SYQNX.js} +3 -3
- package/dist/index.js +53 -68
- package/dist/index.js.map +1 -1
- package/dist/zones-PCUEPXHD.js +11 -0
- package/package.json +1 -1
- package/dist/chunk-HVBIMS4W.js.map +0 -1
- package/dist/zones-QZPJFIDD.js +0 -11
- /package/dist/{chunk-ZTOVYAS4.js.map → chunk-IDTF7SUY.js.map} +0 -0
- /package/dist/{chunk-JBKEAGXV.js.map → chunk-NB3OEY2P.js.map} +0 -0
- /package/dist/{dns-43EGJW7E.js.map → dns-2F5SYQNX.js.map} +0 -0
- /package/dist/{zones-QZPJFIDD.js.map → zones-PCUEPXHD.js.map} +0 -0
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-
|
|
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-
|
|
57
|
+
//# sourceMappingURL=chunk-IDTF7SUY.js.map
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
CliError,
|
|
4
4
|
cfPaginate,
|
|
5
5
|
cfRequest
|
|
6
|
-
} from "./chunk-
|
|
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-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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-
|
|
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-
|
|
11
|
-
import "./chunk-
|
|
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-
|
|
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-
|
|
5
|
+
} from "./chunk-NB3OEY2P.js";
|
|
6
6
|
import {
|
|
7
7
|
createCname,
|
|
8
8
|
deleteDnsRecord,
|
|
9
9
|
findCname,
|
|
10
10
|
isManagedDns
|
|
11
|
-
} from "./chunk-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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-
|
|
1363
|
-
const { listZones: listZones3 } = await import("./zones-
|
|
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-
|
|
1401
|
-
const { listZones: listZones3 } = await import("./zones-
|
|
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
|
|
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 ${
|
|
1849
|
-
`Secret ${
|
|
1833
|
+
`URL ${pc.green(url)}`,
|
|
1834
|
+
`Secret ${pc.bold(secret)} ${pc.dim("(store it \u2014 the client needs it, shown once)")}`,
|
|
1850
1835
|
"",
|
|
1851
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1913
|
-
` ${
|
|
1914
|
-
` ${
|
|
1915
|
-
` ${
|
|
1916
|
-
` ${
|
|
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
|
);
|