@iamken/cloudtunnel 0.8.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/dist/index.js CHANGED
@@ -2,35 +2,42 @@
2
2
  import {
3
3
  listZones,
4
4
  resolveZone
5
- } from "./chunk-CWJA4L7J.js";
5
+ } from "./chunk-JBKEAGXV.js";
6
6
  import {
7
7
  createCname,
8
8
  deleteDnsRecord,
9
9
  findCname,
10
10
  isManagedDns
11
- } from "./chunk-NZKGIDIW.js";
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,
24
30
  registryFile,
25
31
  reportError,
26
32
  resolveCf,
27
- saveConfig
28
- } from "./chunk-RWR6VXNB.js";
33
+ saveConfig,
34
+ scanCacheFile
35
+ } from "./chunk-HVBIMS4W.js";
29
36
 
30
37
  // src/index.ts
31
38
  import { Command } from "commander";
32
39
  import { createRequire } from "module";
33
- import pc2 from "picocolors";
40
+ import pc3 from "picocolors";
34
41
 
35
42
  // src/config/legacy-migrate.ts
36
43
  import { existsSync as existsSync3, readFileSync, renameSync, writeFileSync as writeFileSync4 } from "fs";
@@ -164,6 +171,17 @@ function formatTunnelSpec(s) {
164
171
  var fqdnFor = (subdomain, zone) => subdomain === "@" ? zone : `${subdomain}.${zone}`;
165
172
  var serviceSlug = (fqdn) => fqdn.replace(/[^a-zA-Z0-9]+/g, "-");
166
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
+ }
167
185
  const spec = formatTunnelSpec({ subdomain: p.subdomain, port: p.port, host: p.host });
168
186
  return [
169
187
  "up",
@@ -571,13 +589,17 @@ function openBrowser(url) {
571
589
  }
572
590
 
573
591
  // src/config/resolve-identity.ts
574
- var API_BASE = "https://api.cloudflare.com/client/v4";
575
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;
576
600
  let res;
577
601
  try {
578
- res = await fetch(`${API_BASE}${path}`, {
579
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
580
- });
602
+ res = await fetch(`${base}${path}`, { headers });
581
603
  } catch {
582
604
  throw new CliError("Could not reach the Cloudflare API (network error).");
583
605
  }
@@ -637,6 +659,22 @@ async function acquireToken(opts) {
637
659
  return { token, fromEnv: false };
638
660
  }
639
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
+ }
640
678
  if (process.stdout.isTTY) clack.intro("cloudtunnel \xB7 connect to Cloudflare");
641
679
  const { token, fromEnv } = await acquireToken(opts);
642
680
  const spin = clack.spinner();
@@ -659,12 +697,29 @@ async function runLoginFlow(opts = {}) {
659
697
  defaultZone = (await selectOne("Select a default domain", zones, (z) => z.name)).name;
660
698
  }
661
699
  }
662
- saveConfig({ apiToken: fromEnv ? void 0 : token, accountId: account.id, defaultZone });
700
+ saveConfig(buildMergedConfig(loadConfig(), {
701
+ token,
702
+ fromEnv,
703
+ accountId: account.id,
704
+ defaultZone,
705
+ apiBase: opts.apiBase,
706
+ relaySecret: relaySecretInput
707
+ }));
663
708
  const summary = `Logged in as ${account.name}${defaultZone ? ` \xB7 default domain ${defaultZone}` : ""}`;
664
709
  if (process.stdout.isTTY) clack.outro(summary);
665
710
  else say.ok(summary);
666
711
  if (!defaultZone) say.dim("No default domain set \u2014 pass -d <domain> on `up`, or re-run `login --zone <domain>`.");
667
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
+ }
668
723
  function showStatus() {
669
724
  const config = loadConfig();
670
725
  const token = process.env.CLOUDFLARE_API_TOKEN ?? config.apiToken;
@@ -676,10 +731,14 @@ function showStatus() {
676
731
  say.info(`Token: ${redactToken(token)} (${source})`);
677
732
  say.info(`Account: ${config.accountId ?? "(from env / unresolved)"}`);
678
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)"}`);
679
738
  say.dim(`Config: ${configFile}`);
680
739
  }
681
740
  function registerLogin(program) {
682
- 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) => {
683
742
  if (opts.status) return showStatus();
684
743
  await runLoginFlow(opts);
685
744
  });
@@ -1197,12 +1256,32 @@ async function rollback(cf, zoneId, tunnelId, dnsRecordId, hostname) {
1197
1256
  await deleteTunnel(cf, tunnelId);
1198
1257
  } catch {
1199
1258
  clean = false;
1200
- say.warn(`Left tunnel ${tunnelId} behind \u2014 remove it with \`cloudtunnel down ${hostname}\`.`);
1259
+ say.warn(`Left tunnel ${tunnelId} behind \u2014 remove it with \`cloudtunnel delete ${tunnelId} -f\`.`);
1201
1260
  }
1202
1261
  }
1203
1262
  return clean;
1204
1263
  }
1205
1264
 
1265
+ // src/core/unmanaged-scan-cache.ts
1266
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync7 } from "fs";
1267
+ function saveUnmanagedScan(rows) {
1268
+ try {
1269
+ ensureDirs();
1270
+ const cache = {};
1271
+ for (const [num, row] of rows) cache[String(num)] = row;
1272
+ writeFileSync7(scanCacheFile, JSON.stringify(cache, null, 2), { mode: 384 });
1273
+ } catch {
1274
+ }
1275
+ }
1276
+ function lookupUnmanagedByIndex(num) {
1277
+ try {
1278
+ const cache = JSON.parse(readFileSync4(scanCacheFile, "utf8"));
1279
+ return cache[String(num)];
1280
+ } catch {
1281
+ return void 0;
1282
+ }
1283
+ }
1284
+
1206
1285
  // src/core/orchestrator-manage.ts
1207
1286
  var tunnelIdFromCname2 = (content) => content.replace(/\.cfargotunnel\.com\.?$/, "");
1208
1287
  var isNotFound = (err) => err instanceof CliError && err.status === 404;
@@ -1271,6 +1350,34 @@ async function removeTunnelSubdomain(cf, target, opts = {}) {
1271
1350
  await removeEntry(fqdn);
1272
1351
  if (!opts.quiet) say.ok(`Released ${fqdn}`);
1273
1352
  }
1353
+ var TUNNEL_ID_PREFIX_RE = /^[0-9a-f][0-9a-f-]{5,}$/;
1354
+ async function resolveRemoteTarget(cf, target) {
1355
+ if (!TUNNEL_ID_PREFIX_RE.test(target)) return null;
1356
+ const matches = (await listTunnels(cf)).filter((t) => t.id.startsWith(target));
1357
+ if (matches.length > 1) {
1358
+ throw new CliError(`"${target}" matches ${matches.length} tunnels on the account.`, { hint: "use a longer id prefix" });
1359
+ }
1360
+ const tunnel = matches[0];
1361
+ if (!tunnel) return null;
1362
+ const { listCargoCnames } = await import("./dns-43EGJW7E.js");
1363
+ const { listZones: listZones3 } = await import("./zones-QZPJFIDD.js");
1364
+ for (const zone of await listZones3(cf.token)) {
1365
+ const rec = (await listCargoCnames(cf.token, zone.id)).find((r) => tunnelIdFromCname2(r.content) === tunnel.id);
1366
+ if (rec) return { tunnel, fqdn: rec.name };
1367
+ }
1368
+ return { tunnel };
1369
+ }
1370
+ async function removeTunnelById(cf, tunnel, opts = {}) {
1371
+ if (!isManagedTunnel(tunnel) && !opts.force) {
1372
+ throw new CliError(`Tunnel ${tunnel.id} is not managed by cloudtunnel.`, { hint: "pass --force to release it" });
1373
+ }
1374
+ if (opts.dryRun) {
1375
+ say.info(`Would release: tunnel ${tunnel.id} (no DNS record)`);
1376
+ return;
1377
+ }
1378
+ await deleteTunnelWithConnections(cf, tunnel.id);
1379
+ if (!opts.quiet) say.ok(`Released tunnel ${tunnel.id}`);
1380
+ }
1274
1381
  async function listAll(cf, opts = {}) {
1275
1382
  const entries = await reconcile();
1276
1383
  const tunnels = new Map((await listTunnels(cf)).map((t) => [t.id, t]));
@@ -1290,16 +1397,24 @@ async function listAll(cf, opts = {}) {
1290
1397
  };
1291
1398
  });
1292
1399
  if (opts.all) {
1293
- const { listCargoCnames } = await import("./dns-5OXFAQ4D.js");
1294
- const { listZones: listZones3 } = await import("./zones-QYV3DSEY.js");
1400
+ const { listCargoCnames } = await import("./dns-43EGJW7E.js");
1401
+ const { listZones: listZones3 } = await import("./zones-QZPJFIDD.js");
1295
1402
  const tracked = new Set(entries.map(entryFqdn));
1403
+ const unmanaged = [];
1296
1404
  for (const zone of await listZones3(cf.token)) {
1297
1405
  for (const rec of await listCargoCnames(cf.token, zone.id)) {
1298
- if (!tracked.has(rec.name)) {
1299
- rows.push({ num: "-", url: `https://${rec.name}`, target: "-", protocol: "-", state: "unmanaged", service: "-", pid: "-", managed: false });
1300
- }
1406
+ if (!tracked.has(rec.name)) unmanaged.push(rec);
1301
1407
  }
1302
1408
  }
1409
+ unmanaged.sort((a, b) => a.name.localeCompare(b.name));
1410
+ let next = Math.max(0, ...entries.map((e) => e.index ?? 0)) + 1;
1411
+ const scan = /* @__PURE__ */ new Map();
1412
+ for (const rec of unmanaged) {
1413
+ scan.set(next, { fqdn: rec.name, tunnelId: tunnelIdFromCname2(rec.content) });
1414
+ rows.push({ num: String(next), url: `https://${rec.name}`, target: "-", protocol: "-", state: "unmanaged", service: "-", pid: "-", managed: false });
1415
+ next++;
1416
+ }
1417
+ saveUnmanagedScan(scan);
1303
1418
  }
1304
1419
  return rows;
1305
1420
  }
@@ -1376,6 +1491,17 @@ async function startTunnels(cf, bin, items, opts = {}) {
1376
1491
  say.dim("Ctrl-C stops and releases them.");
1377
1492
  }
1378
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
+
1379
1505
  // src/core/transport-protocol.ts
1380
1506
  function parseTransportProtocol(value) {
1381
1507
  if (value === "auto" || value === "http2" || value === "quic") return value;
@@ -1404,15 +1530,6 @@ async function promptPort() {
1404
1530
  );
1405
1531
  return Number(input);
1406
1532
  }
1407
- async function resolveDomain(cf, opts, creds) {
1408
- if (opts.domain) return opts.domain;
1409
- const zones = await listZones(cf.token);
1410
- if (zones.length === 0) throw new CliError("No domains found in this Cloudflare account.");
1411
- if (zones.length === 1) return zones[0].name;
1412
- if (process.stdin.isTTY) return (await selectOne("Choose a domain", zones, (z) => z.name)).name;
1413
- if (creds.defaultZone) return creds.defaultZone;
1414
- throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
1415
- }
1416
1533
  async function resolveSpecSubdomain(spec, opts) {
1417
1534
  if (spec.subdomain !== void 0) return spec.subdomain;
1418
1535
  if (opts.yes || !process.stdin.isTTY) return void 0;
@@ -1528,7 +1645,7 @@ async function deleteOne(cf, fqdn, opts) {
1528
1645
  else say.ok(`Removed boot service ${serviceName(fqdn)}`);
1529
1646
  }
1530
1647
  function registerDelete(program) {
1531
- program.command("delete").argument("[targets...]", "subdomains to remove by # / name / URL (omit with --all)").description("Release tunnel(s) \u2014 deletes the tunnel + DNS, and any systemd boot service").option("--all", "release every tracked subdomain").option("-f, --force", "release even a resource not created by cloudtunnel").option("--dry-run", "show what would be released without doing it").action(async (targets, opts) => {
1648
+ program.command("delete").argument("[targets...]", "subdomains to remove by # / name / URL / tunnel-id (omit with --all)").description("Release tunnel(s) \u2014 deletes the tunnel + DNS, and any systemd boot service").option("--all", "release every tracked subdomain").option("-f, --force", "release even a resource not created by cloudtunnel").option("--dry-run", "show what would be released without doing it").action(async (targets, opts) => {
1532
1649
  await ensureAuth();
1533
1650
  const cf = resolveCf();
1534
1651
  if (opts.all) {
@@ -1547,18 +1664,35 @@ function registerDelete(program) {
1547
1664
  }
1548
1665
  return;
1549
1666
  }
1550
- if (targets.length === 0) throw new CliError("Pass a subdomain (# / name / URL) or --all.");
1667
+ if (targets.length === 0) throw new CliError("Pass a subdomain (# / name / URL / tunnel-id) or --all.");
1551
1668
  for (const target of targets) {
1552
- const { fqdn } = resolveTarget(target);
1669
+ let fqdn;
1670
+ try {
1671
+ ({ fqdn } = resolveTarget(target));
1672
+ } catch (err) {
1673
+ const scanned = /^\d+$/.test(target) ? lookupUnmanagedByIndex(Number(target)) : void 0;
1674
+ if (scanned) {
1675
+ say.info(`${target} \u2192 ${scanned.fqdn} (unmanaged, numbered by the last \`ls --all\`)`);
1676
+ fqdn = scanned.fqdn;
1677
+ } else {
1678
+ const remote = await resolveRemoteTarget(cf, target);
1679
+ if (!remote) throw err;
1680
+ if (!remote.fqdn) {
1681
+ await removeTunnelById(cf, remote.tunnel, opts);
1682
+ continue;
1683
+ }
1684
+ fqdn = remote.fqdn;
1685
+ }
1686
+ }
1553
1687
  await deleteOne(cf, fqdn, opts);
1554
1688
  }
1555
1689
  });
1556
1690
  }
1557
1691
 
1558
1692
  // src/commands/logs.ts
1559
- import { closeSync, existsSync as existsSync6, openSync as openSync2, readFileSync as readFileSync4, readSync, statSync, watch } from "fs";
1693
+ import { closeSync, existsSync as existsSync6, openSync as openSync2, readFileSync as readFileSync5, readSync, statSync, watch } from "fs";
1560
1694
  function printTail(file, n) {
1561
- const lines = readFileSync4(file, "utf8").split("\n");
1695
+ const lines = readFileSync5(file, "utf8").split("\n");
1562
1696
  const tail = lines.slice(-n).join("\n");
1563
1697
  process.stdout.write(tail.endsWith("\n") ? tail : `${tail}
1564
1698
  `);
@@ -1599,6 +1733,173 @@ function registerLogs(program) {
1599
1733
  });
1600
1734
  }
1601
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
+
1602
1903
  // src/index.ts
1603
1904
  var require2 = createRequire(import.meta.url);
1604
1905
  var pkg = require2("../package.json");
@@ -1608,15 +1909,15 @@ function buildProgram() {
1608
1909
  program.addHelpText(
1609
1910
  "before",
1610
1911
  [
1611
- pc2.bold("Quickstart:"),
1612
- ` ${pc2.cyan("cloudtunnel login")} once \u2014 paste a token (or set CLOUDFLARE_API_TOKEN)`,
1613
- ` ${pc2.cyan("cloudtunnel 8080")} your local :8080 goes live at an HTTPS URL`,
1614
- ` ${pc2.cyan("cloudtunnel api:8080")} api.<domain> \u2192 localhost:8080`,
1615
- ` ${pc2.cyan("cloudtunnel ls")} list tunnels ${pc2.dim("\xB7")} ${pc2.cyan("cloudtunnel delete <#>")} remove one`,
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`,
1616
1917
  ""
1617
1918
  ].join("\n")
1618
1919
  );
1619
- for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs]) {
1920
+ for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs, registerRelay]) {
1620
1921
  register(program);
1621
1922
  }
1622
1923
  return program;