@iamken/cloudtunnel 0.9.0 → 0.10.0
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 +59 -0
- package/dist/{chunk-3KBLIPKG.js → chunk-HVBIMS4W.js} +46 -4
- package/dist/chunk-HVBIMS4W.js.map +1 -0
- package/dist/{chunk-VR5RHOTJ.js → chunk-JBKEAGXV.js} +2 -2
- package/dist/{chunk-LG5ST75Z.js → chunk-ZTOVYAS4.js} +2 -2
- package/dist/{dns-C7WXQOED.js → dns-43EGJW7E.js} +3 -3
- package/dist/index.js +256 -29
- package/dist/index.js.map +1 -1
- package/dist/zones-QZPJFIDD.js +11 -0
- package/package.json +1 -1
- package/dist/chunk-3KBLIPKG.js.map +0 -1
- package/dist/zones-KPRAW4JL.js +0 -11
- /package/dist/{chunk-VR5RHOTJ.js.map → chunk-JBKEAGXV.js.map} +0 -0
- /package/dist/{chunk-LG5ST75Z.js.map → chunk-ZTOVYAS4.js.map} +0 -0
- /package/dist/{dns-C7WXQOED.js.map → dns-43EGJW7E.js.map} +0 -0
- /package/dist/{zones-KPRAW4JL.js.map → zones-QZPJFIDD.js.map} +0 -0
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,64 @@ 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
|
+
---
|
|
163
|
+
|
|
105
164
|
## 🔁 Run on boot (`--service`)
|
|
106
165
|
|
|
107
166
|
Add **`--service`** to register each subdomain as a native OS service so it comes
|
|
@@ -79,8 +79,37 @@ function getCredentials() {
|
|
|
79
79
|
return { apiToken, accountId, defaultZone: config.defaultZone };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// src/config/api-base.ts
|
|
83
|
+
var DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
84
|
+
function getApiBase() {
|
|
85
|
+
const raw = process.env.CLOUDTUNNEL_API_BASE?.trim() || loadConfig().apiBase || DEFAULT_API_BASE;
|
|
86
|
+
return raw.replace(/\/+$/, "");
|
|
87
|
+
}
|
|
88
|
+
function isHttpUrl(s) {
|
|
89
|
+
try {
|
|
90
|
+
const u = new URL(s);
|
|
91
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/config/relay-secret.ts
|
|
98
|
+
import { randomBytes } from "crypto";
|
|
99
|
+
var RELAY_SECRET_HEADER = "X-CT-Relay-Secret";
|
|
100
|
+
function getRelaySecret() {
|
|
101
|
+
return process.env.CLOUDTUNNEL_RELAY_SECRET ?? loadConfig().relaySecret;
|
|
102
|
+
}
|
|
103
|
+
function ensureRelaySecret() {
|
|
104
|
+
const existing = getRelaySecret();
|
|
105
|
+
if (existing) return existing;
|
|
106
|
+
const secret = randomBytes(24).toString("base64url");
|
|
107
|
+
const prev = loadConfig();
|
|
108
|
+
saveConfig({ ...prev, relaySecret: secret });
|
|
109
|
+
return secret;
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
// src/cloudflare/client.ts
|
|
83
|
-
var API_BASE = "https://api.cloudflare.com/client/v4";
|
|
84
113
|
var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
85
114
|
var MAX_ATTEMPTS = 4;
|
|
86
115
|
function resolveCf() {
|
|
@@ -94,12 +123,19 @@ function resolveCf() {
|
|
|
94
123
|
}
|
|
95
124
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
96
125
|
async function cfRequest(token, method, path, body) {
|
|
126
|
+
const base = getApiBase();
|
|
127
|
+
const secret = getRelaySecret();
|
|
128
|
+
const headers = {
|
|
129
|
+
Authorization: `Bearer ${token}`,
|
|
130
|
+
"Content-Type": "application/json"
|
|
131
|
+
};
|
|
132
|
+
if (secret && base !== DEFAULT_API_BASE) headers[RELAY_SECRET_HEADER] = secret;
|
|
97
133
|
for (let attempt = 0; ; attempt++) {
|
|
98
134
|
let res;
|
|
99
135
|
try {
|
|
100
|
-
res = await fetch(`${
|
|
136
|
+
res = await fetch(`${base}${path}`, {
|
|
101
137
|
method,
|
|
102
|
-
headers
|
|
138
|
+
headers,
|
|
103
139
|
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
104
140
|
});
|
|
105
141
|
} catch {
|
|
@@ -147,8 +183,14 @@ export {
|
|
|
147
183
|
loadConfig,
|
|
148
184
|
saveConfig,
|
|
149
185
|
getCredentials,
|
|
186
|
+
DEFAULT_API_BASE,
|
|
187
|
+
getApiBase,
|
|
188
|
+
isHttpUrl,
|
|
189
|
+
RELAY_SECRET_HEADER,
|
|
190
|
+
getRelaySecret,
|
|
191
|
+
ensureRelaySecret,
|
|
150
192
|
resolveCf,
|
|
151
193
|
cfRequest,
|
|
152
194
|
cfPaginate
|
|
153
195
|
};
|
|
154
|
-
//# sourceMappingURL=chunk-
|
|
196
|
+
//# sourceMappingURL=chunk-HVBIMS4W.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ui/errors.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/** 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 { 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 { 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;\n return raw.replace(/\\/+$/, \"\");\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 } from \"../ui/errors.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 secret = getRelaySecret();\n const headers: Record<string, string> = {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n };\n if (secret && base !== DEFAULT_API_BASE) 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 {\n throw new CliError(\"Could not reach the Cloudflare API (network error).\");\n }\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>;\n if (!res.ok || !env.success) {\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":[]}
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
CliError,
|
|
4
4
|
cfPaginate,
|
|
5
5
|
cfRequest
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-HVBIMS4W.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-JBKEAGXV.js.map
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
CliError,
|
|
4
4
|
cfPaginate,
|
|
5
5
|
cfRequest
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-HVBIMS4W.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-ZTOVYAS4.js.map
|
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
findCname,
|
|
8
8
|
isManagedDns,
|
|
9
9
|
listCargoCnames
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import "./chunk-
|
|
10
|
+
} from "./chunk-ZTOVYAS4.js";
|
|
11
|
+
import "./chunk-HVBIMS4W.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-43EGJW7E.js.map
|
package/dist/index.js
CHANGED
|
@@ -2,22 +2,28 @@
|
|
|
2
2
|
import {
|
|
3
3
|
listZones,
|
|
4
4
|
resolveZone
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-JBKEAGXV.js";
|
|
6
6
|
import {
|
|
7
7
|
createCname,
|
|
8
8
|
deleteDnsRecord,
|
|
9
9
|
findCname,
|
|
10
10
|
isManagedDns
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-ZTOVYAS4.js";
|
|
12
12
|
import {
|
|
13
13
|
CliError,
|
|
14
|
+
DEFAULT_API_BASE,
|
|
15
|
+
RELAY_SECRET_HEADER,
|
|
14
16
|
__export,
|
|
15
17
|
binDir,
|
|
16
18
|
cfPaginate,
|
|
17
19
|
cfRequest,
|
|
18
20
|
configFile,
|
|
19
21
|
ensureDirs,
|
|
22
|
+
ensureRelaySecret,
|
|
23
|
+
getApiBase,
|
|
20
24
|
getCredentials,
|
|
25
|
+
getRelaySecret,
|
|
26
|
+
isHttpUrl,
|
|
21
27
|
loadConfig,
|
|
22
28
|
logDir,
|
|
23
29
|
profilesFile,
|
|
@@ -26,12 +32,12 @@ import {
|
|
|
26
32
|
resolveCf,
|
|
27
33
|
saveConfig,
|
|
28
34
|
scanCacheFile
|
|
29
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-HVBIMS4W.js";
|
|
30
36
|
|
|
31
37
|
// src/index.ts
|
|
32
38
|
import { Command } from "commander";
|
|
33
39
|
import { createRequire } from "module";
|
|
34
|
-
import
|
|
40
|
+
import pc3 from "picocolors";
|
|
35
41
|
|
|
36
42
|
// src/config/legacy-migrate.ts
|
|
37
43
|
import { existsSync as existsSync3, readFileSync, renameSync, writeFileSync as writeFileSync4 } from "fs";
|
|
@@ -165,6 +171,17 @@ function formatTunnelSpec(s) {
|
|
|
165
171
|
var fqdnFor = (subdomain, zone) => subdomain === "@" ? zone : `${subdomain}.${zone}`;
|
|
166
172
|
var serviceSlug = (fqdn) => fqdn.replace(/[^a-zA-Z0-9]+/g, "-");
|
|
167
173
|
function buildUpArgs(p) {
|
|
174
|
+
if (p.command === "relay") {
|
|
175
|
+
return [
|
|
176
|
+
"relay",
|
|
177
|
+
p.subdomain,
|
|
178
|
+
"-d",
|
|
179
|
+
p.zone,
|
|
180
|
+
...p.proto === "https" ? ["--proto", "https"] : [],
|
|
181
|
+
"-f",
|
|
182
|
+
"-y"
|
|
183
|
+
];
|
|
184
|
+
}
|
|
168
185
|
const spec = formatTunnelSpec({ subdomain: p.subdomain, port: p.port, host: p.host });
|
|
169
186
|
return [
|
|
170
187
|
"up",
|
|
@@ -572,13 +589,17 @@ function openBrowser(url) {
|
|
|
572
589
|
}
|
|
573
590
|
|
|
574
591
|
// src/config/resolve-identity.ts
|
|
575
|
-
var API_BASE = "https://api.cloudflare.com/client/v4";
|
|
576
592
|
async function cfGet(path, token) {
|
|
593
|
+
const base = getApiBase();
|
|
594
|
+
const secret = getRelaySecret();
|
|
595
|
+
const headers = {
|
|
596
|
+
Authorization: `Bearer ${token}`,
|
|
597
|
+
"Content-Type": "application/json"
|
|
598
|
+
};
|
|
599
|
+
if (secret && base !== DEFAULT_API_BASE) headers[RELAY_SECRET_HEADER] = secret;
|
|
577
600
|
let res;
|
|
578
601
|
try {
|
|
579
|
-
res = await fetch(`${
|
|
580
|
-
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
|
581
|
-
});
|
|
602
|
+
res = await fetch(`${base}${path}`, { headers });
|
|
582
603
|
} catch {
|
|
583
604
|
throw new CliError("Could not reach the Cloudflare API (network error).");
|
|
584
605
|
}
|
|
@@ -638,6 +659,22 @@ async function acquireToken(opts) {
|
|
|
638
659
|
return { token, fromEnv: false };
|
|
639
660
|
}
|
|
640
661
|
async function runLoginFlow(opts = {}) {
|
|
662
|
+
if (opts.apiBase && !isHttpUrl(opts.apiBase)) {
|
|
663
|
+
throw new CliError(`Invalid --api-base "${opts.apiBase}".`, {
|
|
664
|
+
hint: "must be an http(s) URL, e.g. https://cfapi.example.com/client/v4"
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
if (opts.tokenStdin && opts.relaySecretStdin) {
|
|
668
|
+
throw new CliError("Can't read both the token and the relay secret from stdin.", {
|
|
669
|
+
hint: "run login twice, or set one via env (CLOUDFLARE_API_TOKEN / CLOUDTUNNEL_RELAY_SECRET)"
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (opts.apiBase) process.env.CLOUDTUNNEL_API_BASE = opts.apiBase;
|
|
673
|
+
let relaySecretInput;
|
|
674
|
+
if (opts.relaySecretStdin) {
|
|
675
|
+
relaySecretInput = await readStdin();
|
|
676
|
+
if (relaySecretInput) process.env.CLOUDTUNNEL_RELAY_SECRET = relaySecretInput;
|
|
677
|
+
}
|
|
641
678
|
if (process.stdout.isTTY) clack.intro("cloudtunnel \xB7 connect to Cloudflare");
|
|
642
679
|
const { token, fromEnv } = await acquireToken(opts);
|
|
643
680
|
const spin = clack.spinner();
|
|
@@ -660,12 +697,29 @@ async function runLoginFlow(opts = {}) {
|
|
|
660
697
|
defaultZone = (await selectOne("Select a default domain", zones, (z) => z.name)).name;
|
|
661
698
|
}
|
|
662
699
|
}
|
|
663
|
-
saveConfig(
|
|
700
|
+
saveConfig(buildMergedConfig(loadConfig(), {
|
|
701
|
+
token,
|
|
702
|
+
fromEnv,
|
|
703
|
+
accountId: account.id,
|
|
704
|
+
defaultZone,
|
|
705
|
+
apiBase: opts.apiBase,
|
|
706
|
+
relaySecret: relaySecretInput
|
|
707
|
+
}));
|
|
664
708
|
const summary = `Logged in as ${account.name}${defaultZone ? ` \xB7 default domain ${defaultZone}` : ""}`;
|
|
665
709
|
if (process.stdout.isTTY) clack.outro(summary);
|
|
666
710
|
else say.ok(summary);
|
|
667
711
|
if (!defaultZone) say.dim("No default domain set \u2014 pass -d <domain> on `up`, or re-run `login --zone <domain>`.");
|
|
668
712
|
}
|
|
713
|
+
function buildMergedConfig(prev, args) {
|
|
714
|
+
return {
|
|
715
|
+
...prev,
|
|
716
|
+
apiToken: args.fromEnv ? void 0 : args.token,
|
|
717
|
+
accountId: args.accountId,
|
|
718
|
+
defaultZone: args.defaultZone ?? prev.defaultZone,
|
|
719
|
+
apiBase: args.apiBase ?? prev.apiBase,
|
|
720
|
+
relaySecret: args.relaySecret ?? prev.relaySecret
|
|
721
|
+
};
|
|
722
|
+
}
|
|
669
723
|
function showStatus() {
|
|
670
724
|
const config = loadConfig();
|
|
671
725
|
const token = process.env.CLOUDFLARE_API_TOKEN ?? config.apiToken;
|
|
@@ -677,10 +731,14 @@ function showStatus() {
|
|
|
677
731
|
say.info(`Token: ${redactToken(token)} (${source})`);
|
|
678
732
|
say.info(`Account: ${config.accountId ?? "(from env / unresolved)"}`);
|
|
679
733
|
say.info(`Domain: ${config.defaultZone ?? "(none)"}`);
|
|
734
|
+
const baseSrc = process.env.CLOUDTUNNEL_API_BASE ? "env" : config.apiBase ? "config" : "default";
|
|
735
|
+
say.info(`Base: ${getApiBase()} (${baseSrc})`);
|
|
736
|
+
const secretSrc = process.env.CLOUDTUNNEL_RELAY_SECRET ? "env" : config.relaySecret ? "config" : void 0;
|
|
737
|
+
say.info(`Relay secret: ${secretSrc ? `set (${secretSrc})` : "(none)"}`);
|
|
680
738
|
say.dim(`Config: ${configFile}`);
|
|
681
739
|
}
|
|
682
740
|
function registerLogin(program) {
|
|
683
|
-
program.command("login").description("Authenticate with Cloudflare (paste a token once; account + domain auto-resolved)").option("--token-stdin", "read the API token from stdin (scriptable, avoids shell history)").option("--token <token>", "[discouraged] token as an argument (leaks into shell history)").option("--account <id>", "Cloudflare account id (auto-resolved when you have one account)").option("--zone <domain>", "default domain for new tunnels (auto-resolved when you have one)").option("--status", "show current identity (redacted) and exit").action(async (opts) => {
|
|
741
|
+
program.command("login").description("Authenticate with Cloudflare (paste a token once; account + domain auto-resolved)").option("--token-stdin", "read the API token from stdin (scriptable, avoids shell history)").option("--token <token>", "[discouraged] token as an argument (leaks into shell history)").option("--account <id>", "Cloudflare account id (auto-resolved when you have one account)").option("--zone <domain>", "default domain for new tunnels (auto-resolved when you have one)").option("--api-base <url>", "route the CF API through a relay (when api.cloudflare.com is blocked)").option("--relay-secret-stdin", "read the relay shared secret from stdin (pairs with --api-base)").option("--status", "show current identity (redacted) and exit").action(async (opts) => {
|
|
684
742
|
if (opts.status) return showStatus();
|
|
685
743
|
await runLoginFlow(opts);
|
|
686
744
|
});
|
|
@@ -1301,8 +1359,8 @@ async function resolveRemoteTarget(cf, target) {
|
|
|
1301
1359
|
}
|
|
1302
1360
|
const tunnel = matches[0];
|
|
1303
1361
|
if (!tunnel) return null;
|
|
1304
|
-
const { listCargoCnames } = await import("./dns-
|
|
1305
|
-
const { listZones: listZones3 } = await import("./zones-
|
|
1362
|
+
const { listCargoCnames } = await import("./dns-43EGJW7E.js");
|
|
1363
|
+
const { listZones: listZones3 } = await import("./zones-QZPJFIDD.js");
|
|
1306
1364
|
for (const zone of await listZones3(cf.token)) {
|
|
1307
1365
|
const rec = (await listCargoCnames(cf.token, zone.id)).find((r) => tunnelIdFromCname2(r.content) === tunnel.id);
|
|
1308
1366
|
if (rec) return { tunnel, fqdn: rec.name };
|
|
@@ -1339,8 +1397,8 @@ async function listAll(cf, opts = {}) {
|
|
|
1339
1397
|
};
|
|
1340
1398
|
});
|
|
1341
1399
|
if (opts.all) {
|
|
1342
|
-
const { listCargoCnames } = await import("./dns-
|
|
1343
|
-
const { listZones: listZones3 } = await import("./zones-
|
|
1400
|
+
const { listCargoCnames } = await import("./dns-43EGJW7E.js");
|
|
1401
|
+
const { listZones: listZones3 } = await import("./zones-QZPJFIDD.js");
|
|
1344
1402
|
const tracked = new Set(entries.map(entryFqdn));
|
|
1345
1403
|
const unmanaged = [];
|
|
1346
1404
|
for (const zone of await listZones3(cf.token)) {
|
|
@@ -1433,6 +1491,17 @@ async function startTunnels(cf, bin, items, opts = {}) {
|
|
|
1433
1491
|
say.dim("Ctrl-C stops and releases them.");
|
|
1434
1492
|
}
|
|
1435
1493
|
|
|
1494
|
+
// src/core/resolve-domain.ts
|
|
1495
|
+
async function resolveDomain(cf, opts, creds) {
|
|
1496
|
+
if (opts.domain) return opts.domain;
|
|
1497
|
+
const zones = await listZones(cf.token);
|
|
1498
|
+
if (zones.length === 0) throw new CliError("No domains found in this Cloudflare account.");
|
|
1499
|
+
if (zones.length === 1) return zones[0].name;
|
|
1500
|
+
if (process.stdin.isTTY) return (await selectOne("Choose a domain", zones, (z) => z.name)).name;
|
|
1501
|
+
if (creds.defaultZone) return creds.defaultZone;
|
|
1502
|
+
throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1436
1505
|
// src/core/transport-protocol.ts
|
|
1437
1506
|
function parseTransportProtocol(value) {
|
|
1438
1507
|
if (value === "auto" || value === "http2" || value === "quic") return value;
|
|
@@ -1461,15 +1530,6 @@ async function promptPort() {
|
|
|
1461
1530
|
);
|
|
1462
1531
|
return Number(input);
|
|
1463
1532
|
}
|
|
1464
|
-
async function resolveDomain(cf, opts, creds) {
|
|
1465
|
-
if (opts.domain) return opts.domain;
|
|
1466
|
-
const zones = await listZones(cf.token);
|
|
1467
|
-
if (zones.length === 0) throw new CliError("No domains found in this Cloudflare account.");
|
|
1468
|
-
if (zones.length === 1) return zones[0].name;
|
|
1469
|
-
if (process.stdin.isTTY) return (await selectOne("Choose a domain", zones, (z) => z.name)).name;
|
|
1470
|
-
if (creds.defaultZone) return creds.defaultZone;
|
|
1471
|
-
throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
|
|
1472
|
-
}
|
|
1473
1533
|
async function resolveSpecSubdomain(spec, opts) {
|
|
1474
1534
|
if (spec.subdomain !== void 0) return spec.subdomain;
|
|
1475
1535
|
if (opts.yes || !process.stdin.isTTY) return void 0;
|
|
@@ -1673,6 +1733,173 @@ function registerLogs(program) {
|
|
|
1673
1733
|
});
|
|
1674
1734
|
}
|
|
1675
1735
|
|
|
1736
|
+
// src/commands/relay.ts
|
|
1737
|
+
import { openSync as openSync3 } from "fs";
|
|
1738
|
+
import { spawn as spawn3 } from "child_process";
|
|
1739
|
+
import { join as join8 } from "path";
|
|
1740
|
+
import pc2 from "picocolors";
|
|
1741
|
+
|
|
1742
|
+
// src/core/api-proxy-server.ts
|
|
1743
|
+
import http from "http";
|
|
1744
|
+
var DEFAULT_UPSTREAM = "https://api.cloudflare.com";
|
|
1745
|
+
var SECRET_HEADER_LC = RELAY_SECRET_HEADER.toLowerCase();
|
|
1746
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
1747
|
+
"connection",
|
|
1748
|
+
"keep-alive",
|
|
1749
|
+
"proxy-authenticate",
|
|
1750
|
+
"proxy-authorization",
|
|
1751
|
+
"te",
|
|
1752
|
+
"trailer",
|
|
1753
|
+
"transfer-encoding",
|
|
1754
|
+
"upgrade",
|
|
1755
|
+
"host",
|
|
1756
|
+
"content-length"
|
|
1757
|
+
]);
|
|
1758
|
+
function send(res, code, body) {
|
|
1759
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
1760
|
+
res.end(body === void 0 ? "" : JSON.stringify(body));
|
|
1761
|
+
}
|
|
1762
|
+
function startProxy(opts) {
|
|
1763
|
+
const upstream = opts.upstream ?? DEFAULT_UPSTREAM;
|
|
1764
|
+
const upstreamOrigin = new URL(upstream).origin;
|
|
1765
|
+
const server = http.createServer((req, res) => {
|
|
1766
|
+
handle(req, res).catch(() => {
|
|
1767
|
+
if (!res.headersSent) send(res, 500, { error: "relay internal error" });
|
|
1768
|
+
else res.end();
|
|
1769
|
+
});
|
|
1770
|
+
});
|
|
1771
|
+
async function handle(req, res) {
|
|
1772
|
+
if (req.method === "CONNECT" || !req.url || !req.url.startsWith("/")) {
|
|
1773
|
+
return send(res, 400, { error: "origin-form request required" });
|
|
1774
|
+
}
|
|
1775
|
+
let target;
|
|
1776
|
+
try {
|
|
1777
|
+
target = new URL(req.url, upstream);
|
|
1778
|
+
} catch {
|
|
1779
|
+
return send(res, 400, { error: "bad request path" });
|
|
1780
|
+
}
|
|
1781
|
+
if (target.origin !== upstreamOrigin) {
|
|
1782
|
+
return send(res, 400, { error: "path escapes upstream" });
|
|
1783
|
+
}
|
|
1784
|
+
if (req.headers[SECRET_HEADER_LC] !== opts.secret) {
|
|
1785
|
+
return send(res, 403, { error: "relay secret missing or invalid" });
|
|
1786
|
+
}
|
|
1787
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
1788
|
+
let body;
|
|
1789
|
+
if (hasBody) {
|
|
1790
|
+
const chunks = [];
|
|
1791
|
+
for await (const c of req) chunks.push(c);
|
|
1792
|
+
body = chunks.length ? Buffer.concat(chunks) : void 0;
|
|
1793
|
+
}
|
|
1794
|
+
const headers = {};
|
|
1795
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
1796
|
+
if (v === void 0) continue;
|
|
1797
|
+
const lk = k.toLowerCase();
|
|
1798
|
+
if (HOP_BY_HOP.has(lk) || lk === SECRET_HEADER_LC) continue;
|
|
1799
|
+
headers[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
1800
|
+
}
|
|
1801
|
+
let up;
|
|
1802
|
+
try {
|
|
1803
|
+
up = await fetch(target.href, { method: req.method, headers, body, redirect: "manual" });
|
|
1804
|
+
} catch {
|
|
1805
|
+
return send(res, 502, { error: "relay upstream unreachable" });
|
|
1806
|
+
}
|
|
1807
|
+
const outHeaders = {
|
|
1808
|
+
"content-type": up.headers.get("content-type") ?? "application/json"
|
|
1809
|
+
};
|
|
1810
|
+
const retryAfter = up.headers.get("retry-after");
|
|
1811
|
+
if (retryAfter) outHeaders["retry-after"] = retryAfter;
|
|
1812
|
+
res.writeHead(up.status, outHeaders);
|
|
1813
|
+
res.end(Buffer.from(await up.arrayBuffer()));
|
|
1814
|
+
}
|
|
1815
|
+
return new Promise((resolve, reject) => {
|
|
1816
|
+
server.once("error", reject);
|
|
1817
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1818
|
+
const port = server.address().port;
|
|
1819
|
+
resolve({
|
|
1820
|
+
port,
|
|
1821
|
+
url: `http://127.0.0.1:${port}`,
|
|
1822
|
+
close: () => new Promise((res) => server.close(() => res()))
|
|
1823
|
+
});
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// src/commands/relay.ts
|
|
1829
|
+
var DEFAULT_SUB = "cfapi";
|
|
1830
|
+
function fqdnFor2(sub, domain) {
|
|
1831
|
+
return sub === "@" ? domain : `${sub}.${domain}`;
|
|
1832
|
+
}
|
|
1833
|
+
function spawnDetachedRelay(sub, domain) {
|
|
1834
|
+
const script = process.argv[1];
|
|
1835
|
+
if (!script) throw new CliError("Cannot resolve the cloudtunnel executable path.");
|
|
1836
|
+
ensureDirs();
|
|
1837
|
+
const logFile = join8(logDir, `relay-${sub === "@" ? "root" : sub}.log`);
|
|
1838
|
+
const fd = openSync3(logFile, "a", 384);
|
|
1839
|
+
const args = [script, "relay", sub, "-d", domain, "-f", "-y"];
|
|
1840
|
+
const child = spawn3(process.execPath, args, { detached: true, stdio: ["ignore", fd, fd] });
|
|
1841
|
+
child.unref();
|
|
1842
|
+
}
|
|
1843
|
+
function relayReadyLines(fqdn, secret, tty) {
|
|
1844
|
+
const url = `https://${fqdn}`;
|
|
1845
|
+
if (!tty) return [`relay ready at ${url}`];
|
|
1846
|
+
const base = `${url}/client/v4`;
|
|
1847
|
+
return [
|
|
1848
|
+
`URL ${pc2.green(url)}`,
|
|
1849
|
+
`Secret ${pc2.bold(secret)} ${pc2.dim("(store it \u2014 the client needs it, shown once)")}`,
|
|
1850
|
+
"",
|
|
1851
|
+
pc2.bold("On the blocked client:"),
|
|
1852
|
+
` export CLOUDTUNNEL_API_BASE=${base}`,
|
|
1853
|
+
` export CLOUDTUNNEL_RELAY_SECRET=${secret}`,
|
|
1854
|
+
` printf %s "$CF_TOKEN" | cloudtunnel login --token-stdin`,
|
|
1855
|
+
pc2.dim(" (mint the CF token on an unblocked host \u2014 dash.cloudflare.com is blocked too)")
|
|
1856
|
+
];
|
|
1857
|
+
}
|
|
1858
|
+
function printRelayReady(fqdn, secret, kind) {
|
|
1859
|
+
const tty = !!process.stdout.isTTY;
|
|
1860
|
+
const lines = relayReadyLines(fqdn, secret, tty);
|
|
1861
|
+
if (tty) note(lines.join("\n"), "relay ready");
|
|
1862
|
+
else say.dim(lines[0]);
|
|
1863
|
+
if (tty && kind === "detach") say.dim(" \u2192 running in background \xB7 stop with: cloudtunnel delete " + fqdn);
|
|
1864
|
+
if (tty && kind === "service") say.dim(" \u2192 boot service installed \xB7 view: cloudtunnel ls \xB7 remove: cloudtunnel delete " + fqdn);
|
|
1865
|
+
}
|
|
1866
|
+
async function runRelay(sub, opts) {
|
|
1867
|
+
const creds = await ensureAuth();
|
|
1868
|
+
const cf = resolveCf();
|
|
1869
|
+
const domain = await resolveDomain(cf, opts, creds);
|
|
1870
|
+
const fqdn = fqdnFor2(sub, domain);
|
|
1871
|
+
if (opts.service) {
|
|
1872
|
+
assertServiceSupported();
|
|
1873
|
+
const secret2 = ensureRelaySecret();
|
|
1874
|
+
installServiceForSpec({ command: "relay", subdomain: sub, port: 0, zone: domain, proto: "http" });
|
|
1875
|
+
printRelayReady(fqdn, secret2, "service");
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
if (opts.detach) {
|
|
1879
|
+
const secret2 = ensureRelaySecret();
|
|
1880
|
+
spawnDetachedRelay(sub, domain);
|
|
1881
|
+
printRelayReady(fqdn, secret2, "detach");
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
const bin = await ensureCloudflared();
|
|
1885
|
+
const secret = ensureRelaySecret();
|
|
1886
|
+
const proxy = await startProxy({ secret });
|
|
1887
|
+
const item = {
|
|
1888
|
+
port: proxy.port,
|
|
1889
|
+
proto: "http",
|
|
1890
|
+
name: sub,
|
|
1891
|
+
zone: domain,
|
|
1892
|
+
defaultZone: creds.defaultZone,
|
|
1893
|
+
force: opts.force,
|
|
1894
|
+
yes: opts.yes
|
|
1895
|
+
};
|
|
1896
|
+
await startTunnels(cf, bin, [item], {});
|
|
1897
|
+
printRelayReady(fqdn, secret, "foreground");
|
|
1898
|
+
}
|
|
1899
|
+
function registerRelay(program) {
|
|
1900
|
+
program.command("relay [subdomain]").description("Expose a locked Cloudflare-API reverse proxy via a tunnel (for clients that can't reach api.cloudflare.com directly)").option("-d, --domain <domain>", "domain for the relay subdomain (prompted from a list if unset)").option("--detach", "run the relay in the background").option("--service", "register the relay as a boot service (systemd \xB7 launchd \xB7 Task Scheduler)").option("-f, --force", "replace a non-tunnel DNS record occupying the hostname").option("-y, --yes", "don't prompt before replacing an existing record").action((subdomain, opts) => runRelay(subdomain ?? DEFAULT_SUB, opts));
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1676
1903
|
// src/index.ts
|
|
1677
1904
|
var require2 = createRequire(import.meta.url);
|
|
1678
1905
|
var pkg = require2("../package.json");
|
|
@@ -1682,15 +1909,15 @@ function buildProgram() {
|
|
|
1682
1909
|
program.addHelpText(
|
|
1683
1910
|
"before",
|
|
1684
1911
|
[
|
|
1685
|
-
|
|
1686
|
-
` ${
|
|
1687
|
-
` ${
|
|
1688
|
-
` ${
|
|
1689
|
-
` ${
|
|
1912
|
+
pc3.bold("Quickstart:"),
|
|
1913
|
+
` ${pc3.cyan("cloudtunnel login")} once \u2014 paste a token (or set CLOUDFLARE_API_TOKEN)`,
|
|
1914
|
+
` ${pc3.cyan("cloudtunnel 8080")} your local :8080 goes live at an HTTPS URL`,
|
|
1915
|
+
` ${pc3.cyan("cloudtunnel api:8080")} api.<domain> \u2192 localhost:8080`,
|
|
1916
|
+
` ${pc3.cyan("cloudtunnel ls")} list tunnels ${pc3.dim("\xB7")} ${pc3.cyan("cloudtunnel delete <#>")} remove one`,
|
|
1690
1917
|
""
|
|
1691
1918
|
].join("\n")
|
|
1692
1919
|
);
|
|
1693
|
-
for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs]) {
|
|
1920
|
+
for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs, registerRelay]) {
|
|
1694
1921
|
register(program);
|
|
1695
1922
|
}
|
|
1696
1923
|
return program;
|