@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 +36 -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 +88 -72
- package/dist/index.js.map +1 -1
- package/dist/zones-PCUEPXHD.js +11 -0
- package/package.json +3 -2
- 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,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-
|
|
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
|
|
|
@@ -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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
1363
|
-
const { listZones: listZones3 } = await import("./zones-
|
|
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-
|
|
1401
|
-
const { listZones: listZones3 } = await import("./zones-
|
|
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
|
|
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 ${
|
|
1849
|
-
`Secret ${
|
|
1863
|
+
`URL ${pc.green(url)}`,
|
|
1864
|
+
`Secret ${pc.bold(secret)} ${pc.dim("(store it \u2014 the client needs it, shown once)")}`,
|
|
1850
1865
|
"",
|
|
1851
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1913
|
-
` ${
|
|
1914
|
-
` ${
|
|
1915
|
-
` ${
|
|
1916
|
-
` ${
|
|
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 {
|