@iamken/cloudtunnel 0.11.5 → 0.12.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,14 +2,14 @@
2
2
  import {
3
3
  listZones,
4
4
  resolveZone
5
- } from "./chunk-OT3UCBBF.js";
5
+ } from "./chunk-J6FFC5ZO.js";
6
6
  import {
7
7
  createCname,
8
8
  deleteDnsRecord,
9
9
  findCname,
10
10
  findDnsRecords,
11
11
  isManagedDns
12
- } from "./chunk-FILVDOPL.js";
12
+ } from "./chunk-XKPTCMTJ.js";
13
13
  import {
14
14
  CliError,
15
15
  DEFAULT_API_BASE,
@@ -18,6 +18,7 @@ import {
18
18
  binDir,
19
19
  cfPaginate,
20
20
  cfRequest,
21
+ configDir,
21
22
  configFile,
22
23
  confirm,
23
24
  dim,
@@ -42,7 +43,7 @@ import {
42
43
  say,
43
44
  scanCacheFile,
44
45
  selectOne
45
- } from "./chunk-7XACBYL7.js";
46
+ } from "./chunk-ZIMTYVHN.js";
46
47
 
47
48
  // src/index.ts
48
49
  import { Command } from "commander";
@@ -50,7 +51,7 @@ import { createRequire } from "module";
50
51
  import pc2 from "picocolors";
51
52
 
52
53
  // src/config/legacy-migrate.ts
53
- import { existsSync as existsSync3, readFileSync, renameSync, writeFileSync as writeFileSync4 } from "fs";
54
+ import { existsSync as existsSync3, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync4 } from "fs";
54
55
 
55
56
  // src/core/service.ts
56
57
  import { join as join5 } from "path";
@@ -134,6 +135,14 @@ function formatTunnelSpec(s) {
134
135
  }
135
136
 
136
137
  // src/core/service-exec.ts
138
+ var SERVICE_SCHEMA = 2;
139
+ var SCHEMA_RE = /cloudtunnel-schema:\s*(\d+)/;
140
+ var schemaMarker = `cloudtunnel-schema: ${SERVICE_SCHEMA}`;
141
+ function schemaOf(text2) {
142
+ const m = SCHEMA_RE.exec(text2);
143
+ return m ? Number(m[1]) : 1;
144
+ }
145
+ var unxml = (s) => s.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
137
146
  var fqdnFor = (subdomain, zone) => subdomain === "@" ? zone : `${subdomain}.${zone}`;
138
147
  var serviceSlug = (fqdn) => fqdn.replace(/[^a-zA-Z0-9]+/g, "-");
139
148
  function buildUpArgs(p) {
@@ -192,19 +201,25 @@ __export(service_systemd_exports, {
192
201
  install: () => install,
193
202
  label: () => label,
194
203
  legacyUnitExists: () => legacyUnitExists,
204
+ listInstalled: () => listInstalled,
205
+ parseUnit: () => parseUnit,
206
+ prepareChanges: () => prepareChanges,
195
207
  removeLegacyUnit: () => removeLegacyUnit,
196
208
  state: () => state,
197
- uninstall: () => uninstall
209
+ uninstall: () => uninstall,
210
+ uninstallLabel: () => uninstallLabel
198
211
  });
199
212
  import { execFileSync } from "child_process";
200
- import { existsSync, writeFileSync } from "fs";
213
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
201
214
  import { tmpdir } from "os";
202
215
  import { dirname, join as join2 } from "path";
216
+ var UNIT_DIR = "/etc/systemd/system";
203
217
  var label = (fqdn) => `cloudtunnel-${serviceSlug(fqdn)}.service`;
204
- var unitPath = (fqdn) => `/etc/systemd/system/${label(fqdn)}`;
218
+ var unitPath = (fqdn) => `${UNIT_DIR}/${label(fqdn)}`;
205
219
  function buildUnit(d) {
206
220
  const nodeBin = dirname(d.nodePath);
207
221
  return [
222
+ `# ${schemaMarker}`,
208
223
  "[Unit]",
209
224
  `Description=cloudtunnel ${d.fqdn} (Cloudflare Tunnel)`,
210
225
  "After=network-online.target",
@@ -219,6 +234,8 @@ function buildUnit(d) {
219
234
  `ExecStart=${d.nodePath} ${d.scriptPath} ${d.argv.join(" ")}`,
220
235
  "Restart=always",
221
236
  "RestartSec=5",
237
+ "KillMode=mixed",
238
+ "TimeoutStopSec=15",
222
239
  "",
223
240
  "[Install]",
224
241
  "WantedBy=multi-user.target",
@@ -256,13 +273,40 @@ function install(d) {
256
273
  privileged(["systemctl", "restart", name]);
257
274
  }
258
275
  function uninstall(fqdn) {
276
+ uninstallLabel(label(fqdn));
277
+ }
278
+ function uninstallLabel(name) {
259
279
  try {
260
- privileged(["systemctl", "disable", "--now", label(fqdn)]);
280
+ privileged(["systemctl", "disable", "--now", name]);
261
281
  } catch {
262
282
  }
263
- privileged(["rm", "-f", unitPath(fqdn)]);
283
+ privileged(["rm", "-f", `${UNIT_DIR}/${name}`]);
264
284
  privileged(["systemctl", "daemon-reload"]);
265
285
  }
286
+ function prepareChanges() {
287
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
288
+ if (!isRoot) execFileSync("sudo", ["-v"], { stdio: "inherit" });
289
+ }
290
+ function parseUnit(name, text2, running) {
291
+ const exec = /^ExecStart=(.+)$/m.exec(text2)?.[1]?.trim();
292
+ return { label: name, command: exec ? exec.split(/\s+/) : [], schema: schemaOf(text2), running };
293
+ }
294
+ function listInstalled() {
295
+ let files = [];
296
+ try {
297
+ files = readdirSync(UNIT_DIR).filter((f) => /^cloudtunnel-.+\.service$/.test(f));
298
+ } catch {
299
+ return [];
300
+ }
301
+ return files.map((f) => {
302
+ let text2 = "";
303
+ try {
304
+ text2 = readFileSync(`${UNIT_DIR}/${f}`, "utf8");
305
+ } catch {
306
+ }
307
+ return parseUnit(f, text2, ["active", "activating", "reloading"].includes(query(["is-active", f])));
308
+ });
309
+ }
266
310
  function state(fqdn) {
267
311
  const name = label(fqdn);
268
312
  if (query(["is-active", name]) === "active") return "active";
@@ -291,11 +335,15 @@ __export(service_launchd_exports, {
291
335
  buildPlist: () => buildPlist,
292
336
  install: () => install2,
293
337
  label: () => label2,
338
+ listInstalled: () => listInstalled2,
339
+ parsePlist: () => parsePlist,
340
+ prepareChanges: () => prepareChanges2,
294
341
  state: () => state2,
295
- uninstall: () => uninstall2
342
+ uninstall: () => uninstall2,
343
+ uninstallLabel: () => uninstallLabel2
296
344
  });
297
345
  import { execFileSync as execFileSync2 } from "child_process";
298
- import { existsSync as existsSync2, mkdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
346
+ import { existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
299
347
  import { dirname as dirname2, join as join3 } from "path";
300
348
  import os2 from "os";
301
349
  var label2 = (fqdn) => `com.cloudtunnel.${serviceSlug(fqdn)}`;
@@ -310,6 +358,7 @@ function buildPlist(d) {
310
358
  '<?xml version="1.0" encoding="UTF-8"?>',
311
359
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
312
360
  '<plist version="1.0">',
361
+ `<!-- ${schemaMarker} -->`,
313
362
  "<dict>",
314
363
  ` <key>Label</key><string>${xml(label2(d.fqdn))}</string>`,
315
364
  " <key>ProgramArguments</key>",
@@ -318,6 +367,7 @@ function buildPlist(d) {
318
367
  " </array>",
319
368
  " <key>RunAtLoad</key><true/>",
320
369
  " <key>KeepAlive</key><true/>",
370
+ " <key>ThrottleInterval</key><integer>10</integer>",
321
371
  " <key>EnvironmentVariables</key>",
322
372
  " <dict>",
323
373
  ` <key>PATH</key><string>${xml(path)}</string>`,
@@ -338,6 +388,11 @@ function launchctl(args) {
338
388
  return out ? out.toString() : "";
339
389
  }
340
390
  }
391
+ function waitUnloaded(agentLabel) {
392
+ for (let i = 0; i < 30 && launchctl(["list", agentLabel]).trim(); i++) {
393
+ execFileSync2("sleep", ["0.5"]);
394
+ }
395
+ }
341
396
  function assertSupported2() {
342
397
  }
343
398
  function install2(d) {
@@ -346,13 +401,41 @@ function install2(d) {
346
401
  const plist = plistPath(d.fqdn);
347
402
  writeFileSync2(plist, buildPlist(d), { mode: 420 });
348
403
  launchctl(["unload", "-w", plist]);
404
+ waitUnloaded(label2(d.fqdn));
349
405
  execFileSync2("launchctl", ["load", "-w", plist], { stdio: "inherit" });
350
406
  }
351
407
  function uninstall2(fqdn) {
352
- const plist = plistPath(fqdn);
408
+ uninstallLabel2(label2(fqdn));
409
+ }
410
+ function uninstallLabel2(agentLabel) {
411
+ const plist = join3(agentsDir(), `${agentLabel}.plist`);
353
412
  launchctl(["unload", "-w", plist]);
354
413
  rmSync(plist, { force: true });
355
414
  }
415
+ function prepareChanges2() {
416
+ }
417
+ function parsePlist(agentLabel, text2, running) {
418
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text2)?.[1] ?? "";
419
+ const command = [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1] ?? ""));
420
+ return { label: agentLabel, command, schema: schemaOf(text2), running };
421
+ }
422
+ function listInstalled2() {
423
+ let files = [];
424
+ try {
425
+ files = readdirSync2(agentsDir()).filter((f) => /^com\.cloudtunnel\..+\.plist$/.test(f));
426
+ } catch {
427
+ return [];
428
+ }
429
+ return files.map((f) => {
430
+ const agentLabel = f.replace(/\.plist$/, "");
431
+ let text2 = "";
432
+ try {
433
+ text2 = readFileSync2(join3(agentsDir(), f), "utf8");
434
+ } catch {
435
+ }
436
+ return parsePlist(agentLabel, text2, /"PID"\s*=/.test(launchctl(["list", agentLabel])));
437
+ });
438
+ }
356
439
  function state2(fqdn) {
357
440
  const info = launchctl(["list", label2(fqdn)]);
358
441
  if (/"PID"\s*=/.test(info)) return "active";
@@ -366,8 +449,12 @@ __export(service_windows_exports, {
366
449
  buildTaskXml: () => buildTaskXml,
367
450
  install: () => install3,
368
451
  label: () => label3,
452
+ listInstalled: () => listInstalled3,
453
+ parseTaskXml: () => parseTaskXml,
454
+ prepareChanges: () => prepareChanges3,
369
455
  state: () => state3,
370
- uninstall: () => uninstall3
456
+ uninstall: () => uninstall3,
457
+ uninstallLabel: () => uninstallLabel3
371
458
  });
372
459
  import { execFileSync as execFileSync3 } from "child_process";
373
460
  import { writeFileSync as writeFileSync3 } from "fs";
@@ -380,7 +467,7 @@ function buildTaskXml(d) {
380
467
  return [
381
468
  '<?xml version="1.0" encoding="UTF-16"?>',
382
469
  '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
383
- ` <RegistrationInfo><Description>cloudtunnel ${xml2(d.fqdn)} (Cloudflare Tunnel)</Description></RegistrationInfo>`,
470
+ ` <RegistrationInfo><Description>cloudtunnel ${xml2(d.fqdn)} (Cloudflare Tunnel) [${schemaMarker}]</Description></RegistrationInfo>`,
384
471
  ` <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xml2(d.user)}</UserId></LogonTrigger></Triggers>`,
385
472
  ` <Principals><Principal id="Author"><UserId>${xml2(d.user)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>`,
386
473
  " <Settings>",
@@ -418,7 +505,25 @@ function install3(d) {
418
505
  schtasks(["/Run", "/TN", name]);
419
506
  }
420
507
  function uninstall3(fqdn) {
421
- schtasks(["/Delete", "/TN", label3(fqdn), "/F"]);
508
+ uninstallLabel3(label3(fqdn));
509
+ }
510
+ function uninstallLabel3(name) {
511
+ schtasks(["/End", "/TN", name]);
512
+ schtasks(["/Delete", "/TN", name, "/F"]);
513
+ }
514
+ function prepareChanges3() {
515
+ }
516
+ function splitArgs(args) {
517
+ return [...args.matchAll(/"([^"]*)"|(\S+)/g)].map((m) => m[1] ?? m[2] ?? "");
518
+ }
519
+ function parseTaskXml(name, xmlText, running) {
520
+ const command = unxml(/<Command>([\s\S]*?)<\/Command>/.exec(xmlText)?.[1] ?? "");
521
+ const args = unxml(/<Arguments>([\s\S]*?)<\/Arguments>/.exec(xmlText)?.[1] ?? "");
522
+ return { label: name, command: command ? [command, ...splitArgs(args)] : [], schema: schemaOf(xmlText), running };
523
+ }
524
+ function listInstalled3() {
525
+ const names = schtasks(["/Query", "/FO", "CSV", "/NH"]).split(/\r?\n/).map((line) => /^"([^"]+)"/.exec(line)?.[1]?.replace(/^\\/, "")).filter((n) => !!n && /^cloudtunnel\\/i.test(n));
526
+ return [...new Set(names)].map((name) => parseTaskXml(name, schtasks(["/Query", "/TN", name, "/XML"]), true));
422
527
  }
423
528
  function state3(fqdn) {
424
529
  const out = schtasks(["/Query", "/TN", label3(fqdn), "/FO", "LIST"]);
@@ -465,6 +570,15 @@ function installServiceForSpec(params) {
465
570
  function uninstallService(fqdn) {
466
571
  pick()?.uninstall(fqdn);
467
572
  }
573
+ function listInstalledServices() {
574
+ return pick()?.listInstalled() ?? [];
575
+ }
576
+ function uninstallServiceLabel(label4) {
577
+ pick()?.uninstallLabel(label4);
578
+ }
579
+ function prepareServiceChanges() {
580
+ pick()?.prepareChanges();
581
+ }
468
582
  function serviceState(fqdn) {
469
583
  return pick()?.state(fqdn) ?? "none";
470
584
  }
@@ -493,7 +607,7 @@ async function migrateLegacyProfiles() {
493
607
  if (!existsSync3(profilesFile) || existsSync3(skipMarker)) return;
494
608
  let profiles;
495
609
  try {
496
- profiles = JSON.parse(readFileSync(profilesFile, "utf8"));
610
+ profiles = JSON.parse(readFileSync3(profilesFile, "utf8"));
497
611
  } catch {
498
612
  return;
499
613
  }
@@ -847,7 +961,7 @@ async function ensureAuth() {
847
961
  // src/connector/binary.ts
848
962
  import { execFileSync as execFileSync5 } from "child_process";
849
963
  import { createHash } from "crypto";
850
- import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync5 } from "fs";
964
+ import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
851
965
  import { join as join6 } from "path";
852
966
  import { gunzipSync } from "zlib";
853
967
  var PINNED_VERSION = "2026.7.3";
@@ -878,7 +992,7 @@ function cachedPath() {
878
992
  }
879
993
  function isMusl() {
880
994
  try {
881
- return process.platform === "linux" && readFileSync2("/usr/bin/ldd", "utf8").includes("musl");
995
+ return process.platform === "linux" && readFileSync4("/usr/bin/ldd", "utf8").includes("musl");
882
996
  } catch {
883
997
  return false;
884
998
  }
@@ -1043,16 +1157,17 @@ async function probeEntryState(entry, gone) {
1043
1157
  }
1044
1158
 
1045
1159
  // src/core/up-runner.ts
1046
- import { join as join7 } from "path";
1047
- import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
1160
+ import { join as join9 } from "path";
1161
+ import { readFileSync as readFileSync9, existsSync as existsSync8 } from "fs";
1048
1162
  import * as clack2 from "@clack/prompts";
1049
1163
 
1050
1164
  // src/connector/process.ts
1051
1165
  import { execFileSync as execFileSync7, spawn as spawn2 } from "child_process";
1052
- import { openSync } from "fs";
1166
+ import { openSync, renameSync as renameSync3, statSync } from "fs";
1167
+ import { createServer } from "net";
1053
1168
 
1054
1169
  // src/connector/registry.ts
1055
- import { existsSync as existsSync5, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "fs";
1170
+ import { existsSync as existsSync5, readFileSync as readFileSync5, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "fs";
1056
1171
  import { execFileSync as execFileSync6 } from "child_process";
1057
1172
  import { readFile } from "fs/promises";
1058
1173
  import os3 from "os";
@@ -1062,7 +1177,7 @@ function entryFqdn(e) {
1062
1177
  }
1063
1178
  function currentBootId() {
1064
1179
  try {
1065
- return readFileSync3("/proc/sys/kernel/random/boot_id", "utf8").trim();
1180
+ return readFileSync5("/proc/sys/kernel/random/boot_id", "utf8").trim();
1066
1181
  } catch {
1067
1182
  if (process.platform === "darwin") {
1068
1183
  try {
@@ -1077,7 +1192,7 @@ function currentBootId() {
1077
1192
  }
1078
1193
  function readRegistry() {
1079
1194
  try {
1080
- return JSON.parse(readFileSync3(registryFile, "utf8"));
1195
+ return JSON.parse(readFileSync5(registryFile, "utf8"));
1081
1196
  } catch {
1082
1197
  return {};
1083
1198
  }
@@ -1167,14 +1282,17 @@ async function isOurConnector(entry) {
1167
1282
  }
1168
1283
  return true;
1169
1284
  }
1285
+ function markConnectorRunning(fqdn, patch) {
1286
+ return patchEntry(fqdn, { bootId: currentBootId(), ...patch, state: "running" });
1287
+ }
1170
1288
  async function reconcile() {
1171
1289
  const entries = listEntries();
1172
1290
  for (const entry of entries) {
1173
- if (entry.state === "running" && !await isOurConnector(entry)) {
1291
+ if (entry.state === "running" && entry.pid && !await isOurConnector(entry)) {
1174
1292
  const fqdn = entryFqdn(entry);
1175
1293
  await mutateRegistry((reg) => {
1176
1294
  const e = reg[fqdn];
1177
- if (e) {
1295
+ if (e && e.state === "running" && e.pid === entry.pid) {
1178
1296
  e.state = "stopped";
1179
1297
  delete e.pid;
1180
1298
  }
@@ -1186,10 +1304,30 @@ async function reconcile() {
1186
1304
 
1187
1305
  // src/connector/process.ts
1188
1306
  var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1307
+ var MAX_LOG_BYTES = 5 * 1024 * 1024;
1308
+ function pickFreePort() {
1309
+ return new Promise((resolve, reject) => {
1310
+ const srv = createServer();
1311
+ srv.unref();
1312
+ srv.on("error", reject);
1313
+ srv.listen(0, "127.0.0.1", () => {
1314
+ const addr = srv.address();
1315
+ const port = typeof addr === "object" && addr ? addr.port : 0;
1316
+ srv.close(() => port ? resolve(port) : reject(new Error("no free port")));
1317
+ });
1318
+ });
1319
+ }
1320
+ function rotateLog(logFile) {
1321
+ try {
1322
+ if (statSync(logFile).size > MAX_LOG_BYTES) renameSync3(logFile, `${logFile}.1`);
1323
+ } catch {
1324
+ }
1325
+ }
1189
1326
  function startConnector(opts) {
1190
- const args = ["tunnel", "--metrics", "127.0.0.1:0", "run"];
1327
+ const args = ["tunnel", "--metrics", `127.0.0.1:${opts.metricsPort ?? 0}`, "run"];
1191
1328
  if (opts.protocol) args.push("--protocol", opts.protocol);
1192
1329
  const env = { ...process.env, TUNNEL_TOKEN: opts.token };
1330
+ rotateLog(opts.logFile);
1193
1331
  const fd = openSync(opts.logFile, "a", 384);
1194
1332
  const child = spawn2(opts.bin, args, { env, detached: opts.detach, stdio: ["ignore", fd, fd] });
1195
1333
  if (!child.pid) throw new CliError("Failed to start the cloudflared connector.");
@@ -1227,6 +1365,54 @@ async function stopConnector(entry) {
1227
1365
  return true;
1228
1366
  }
1229
1367
 
1368
+ // src/connector/hostname-lock.ts
1369
+ import { mkdirSync as mkdirSync2 } from "fs";
1370
+ import { join as join7 } from "path";
1371
+ import lockfile2 from "proper-lockfile";
1372
+ var locksDir = join7(configDir, "locks");
1373
+ var STALE_MS = 15e3;
1374
+ var UPDATE_MS = 5e3;
1375
+ var lockPath = (fqdn) => join7(locksDir, serviceSlug(fqdn));
1376
+ function busyError(fqdn) {
1377
+ return new CliError(`${fqdn} is already being served by another cloudtunnel process.`, {
1378
+ hint: "a boot service may own it \u2014 see `cloudtunnel ls`; release it with `cloudtunnel delete <#>`"
1379
+ });
1380
+ }
1381
+ async function acquireHostnameLock(fqdn, opts = {}) {
1382
+ mkdirSync2(locksDir, { recursive: true, mode: 448 });
1383
+ const waits = Math.ceil((opts.waitMs ?? 0) / 1e3);
1384
+ try {
1385
+ await takeLock(fqdn, waits);
1386
+ } catch (err) {
1387
+ if (err.code === "ELOCKED") throw busyError(fqdn);
1388
+ throw err;
1389
+ }
1390
+ }
1391
+ function takeLock(fqdn, waits) {
1392
+ return lockfile2.lock(lockPath(fqdn), {
1393
+ realpath: false,
1394
+ stale: STALE_MS,
1395
+ update: UPDATE_MS,
1396
+ retries: waits > 0 ? { retries: waits, minTimeout: 1e3, maxTimeout: 1e3 } : 0,
1397
+ onCompromised: () => {
1398
+ say.debug(`[lock] ${fqdn} lock was compromised; re-taking it`);
1399
+ takeLock(fqdn, 0).catch((err) => {
1400
+ say.warn(`Lost ${fqdn} to another cloudtunnel process (${err.message}) \u2014 exiting.`);
1401
+ process.exit(1);
1402
+ });
1403
+ }
1404
+ });
1405
+ }
1406
+ async function assertHostnameFree(fqdn) {
1407
+ let held = false;
1408
+ try {
1409
+ held = await lockfile2.check(lockPath(fqdn), { realpath: false, stale: STALE_MS });
1410
+ } catch {
1411
+ held = false;
1412
+ }
1413
+ if (held) throw busyError(fqdn);
1414
+ }
1415
+
1230
1416
  // src/core/orchestrator-create.ts
1231
1417
  import { randomInt as randomInt2 } from "crypto";
1232
1418
 
@@ -1300,6 +1486,10 @@ async function createTunnelSubdomain(cf, opts) {
1300
1486
  const host = resolveHostSpec(opts, opts.defaultZone);
1301
1487
  const zone = await resolveZone(cf.token, host.zone);
1302
1488
  const existingList = await findDnsRecords(cf.token, zone.id, host.hostname);
1489
+ if (opts.reuse) {
1490
+ const reused = await tryReuseTunnel(cf, zone.id, host, opts, existingList);
1491
+ if (reused) return reused;
1492
+ }
1303
1493
  if (existingList.length > 0) {
1304
1494
  const isTunnelRecord = existingList.every((r) => r.content.endsWith(".cfargotunnel.com"));
1305
1495
  if (!isTunnelRecord && !opts.force) {
@@ -1353,6 +1543,25 @@ async function createTunnelSubdomain(cf, opts) {
1353
1543
  throw err;
1354
1544
  }
1355
1545
  }
1546
+ async function tryReuseTunnel(cf, zoneId, host, opts, existing) {
1547
+ const prev = getEntry(host.hostname);
1548
+ const tunnelId = prev?.tunnelId;
1549
+ const record = existing[0];
1550
+ if (!prev || !tunnelId || existing.length !== 1 || record?.content !== `${tunnelId}.cfargotunnel.com`) return null;
1551
+ let tunnel;
1552
+ try {
1553
+ tunnel = await getTunnel(cf, tunnelId);
1554
+ } catch (err) {
1555
+ if (err instanceof CliError && err.status === 404) return null;
1556
+ throw err;
1557
+ }
1558
+ if (!isManagedTunnel(tunnel) || tunnel.deleted_at) return null;
1559
+ await putIngress(cf, tunnelId, buildIngress({ hostname: host.hostname, port: opts.port, proto: opts.proto, host: opts.host }));
1560
+ const token = await getTunnelToken(cf, tunnelId);
1561
+ await stopConnector(prev);
1562
+ await recordRunning(host, zoneId, tunnelId, record.id, opts);
1563
+ return { host, tunnelId, token };
1564
+ }
1356
1565
  async function recordRunning(host, zoneId, tunnelId, dnsRecordId, opts) {
1357
1566
  await upsertEntry(host.hostname, {
1358
1567
  subdomain: host.subdomain,
@@ -1399,8 +1608,105 @@ async function rollback(cf, zoneId, tunnelId, dnsRecordId, hostname) {
1399
1608
  return clean;
1400
1609
  }
1401
1610
 
1611
+ // src/core/service-inventory.ts
1612
+ import { existsSync as existsSync6 } from "fs";
1613
+
1614
+ // src/core/transport-protocol.ts
1615
+ function parseTransportProtocol(value) {
1616
+ if (value === "auto" || value === "http2" || value === "quic") return value;
1617
+ throw new CliError(`Invalid protocol "${value}".`, { hint: "use auto, http2, or quic" });
1618
+ }
1619
+
1620
+ // src/core/service-inventory.ts
1621
+ function flagValue(argv, flag) {
1622
+ const i = argv.indexOf(flag);
1623
+ return i >= 0 ? argv[i + 1] : void 0;
1624
+ }
1625
+ function parseServiceArgv(argv) {
1626
+ const zone = flagValue(argv, "-d");
1627
+ if (!zone) return void 0;
1628
+ const proto = flagValue(argv, "--proto") === "https" ? "https" : "http";
1629
+ try {
1630
+ if (argv[0] === "relay" && argv[1]) {
1631
+ return { command: "relay", subdomain: argv[1], port: 0, zone, proto };
1632
+ }
1633
+ if (argv[0] === "up" && argv[1]) {
1634
+ const spec = parseTunnelSpec(argv[1]);
1635
+ if (spec.subdomain === void 0) return void 0;
1636
+ const protocolFlag = flagValue(argv, "--protocol");
1637
+ return {
1638
+ subdomain: spec.subdomain,
1639
+ port: spec.port,
1640
+ host: spec.host,
1641
+ zone,
1642
+ proto,
1643
+ protocol: protocolFlag ? parseTransportProtocol(protocolFlag) : void 0
1644
+ };
1645
+ }
1646
+ } catch {
1647
+ return void 0;
1648
+ }
1649
+ return void 0;
1650
+ }
1651
+ function classifyDefinition(def, canonicalLabel, exists = existsSync6) {
1652
+ const [nodePath, scriptPath, ...argv] = def.command;
1653
+ const params = parseServiceArgv(argv);
1654
+ if (!params) return void 0;
1655
+ const fqdn = fqdnFor(params.subdomain, params.zone);
1656
+ const canonical = def.label.toLowerCase() === canonicalLabel(fqdn).toLowerCase();
1657
+ let issue;
1658
+ if (!canonical) issue = "duplicate";
1659
+ else if (!nodePath || !scriptPath || !exists(nodePath) || !exists(scriptPath)) issue = "broken";
1660
+ else if (def.schema < SERVICE_SCHEMA || !argv.includes("--keep-alive")) issue = "stale";
1661
+ else if (!def.running) issue = "dead";
1662
+ return { label: def.label, fqdn, params, canonical, issue };
1663
+ }
1664
+ function inventoryServices() {
1665
+ return listInstalledServices().map((def) => classifyDefinition(def, serviceName)).filter((s) => !!s);
1666
+ }
1667
+ function planRepair(services, opts = {}) {
1668
+ const byFqdn = /* @__PURE__ */ new Map();
1669
+ for (const s of services) byFqdn.set(s.fqdn, [...byFqdn.get(s.fqdn) ?? [], s]);
1670
+ const actions = [];
1671
+ for (const [fqdn, group] of byFqdn) {
1672
+ const main2 = group.find((s) => s.canonical);
1673
+ const extras = group.filter((s) => !s.canonical);
1674
+ const action = { fqdn, remove: extras.map((s) => s.label) };
1675
+ if (!main2) action.reinstall = { params: extras[0].params, reason: "duplicate" };
1676
+ else if (main2.issue) action.reinstall = { params: main2.params, reason: main2.issue };
1677
+ else if (opts.restartRunning) action.reinstall = { params: main2.params, reason: "outdated" };
1678
+ if (action.reinstall || action.remove.length) actions.push(action);
1679
+ }
1680
+ return actions;
1681
+ }
1682
+ function runRepair(actions) {
1683
+ const result = { fixed: [], failed: [] };
1684
+ if (actions.length === 0) return result;
1685
+ prepareServiceChanges();
1686
+ for (const a of actions) {
1687
+ try {
1688
+ for (const label4 of a.remove) uninstallServiceLabel(label4);
1689
+ if (a.reinstall) installServiceForSpec(a.reinstall.params);
1690
+ result.fixed.push(a.fqdn);
1691
+ } catch (err) {
1692
+ result.failed.push({ fqdn: a.fqdn, error: err.message });
1693
+ }
1694
+ }
1695
+ return result;
1696
+ }
1697
+ function serviceIssuesByFqdn() {
1698
+ const map = /* @__PURE__ */ new Map();
1699
+ try {
1700
+ for (const a of planRepair(inventoryServices())) {
1701
+ map.set(a.fqdn, a.reinstall?.reason ?? "duplicate");
1702
+ }
1703
+ } catch {
1704
+ }
1705
+ return map;
1706
+ }
1707
+
1402
1708
  // src/core/unmanaged-scan-cache.ts
1403
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync7 } from "fs";
1709
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "fs";
1404
1710
  function saveUnmanagedScan(rows) {
1405
1711
  try {
1406
1712
  ensureDirs();
@@ -1412,7 +1718,7 @@ function saveUnmanagedScan(rows) {
1412
1718
  }
1413
1719
  function lookupUnmanagedByIndex(num) {
1414
1720
  try {
1415
- const cache = JSON.parse(readFileSync4(scanCacheFile, "utf8"));
1721
+ const cache = JSON.parse(readFileSync6(scanCacheFile, "utf8"));
1416
1722
  return cache[String(num)];
1417
1723
  } catch {
1418
1724
  return void 0;
@@ -1496,8 +1802,8 @@ async function resolveRemoteTarget(cf, target) {
1496
1802
  }
1497
1803
  const tunnel = matches[0];
1498
1804
  if (!tunnel) return null;
1499
- const { listCargoCnames } = await import("./dns-BNDEFUCM.js");
1500
- const { listZones: listZones3 } = await import("./zones-XNCXYCB4.js");
1805
+ const { listCargoCnames } = await import("./dns-VUPKRH46.js");
1806
+ const { listZones: listZones3 } = await import("./zones-CF4XFFZZ.js");
1501
1807
  for (const zone of await listZones3(cf.token)) {
1502
1808
  const rec = (await listCargoCnames(cf.token, zone.id)).find((r) => tunnelIdFromCname2(r.content) === tunnel.id);
1503
1809
  if (rec) return { tunnel, fqdn: rec.name };
@@ -1518,25 +1824,29 @@ async function removeTunnelById(cf, tunnel, opts = {}) {
1518
1824
  async function listAll(cf, opts = {}) {
1519
1825
  const entries = await reconcile();
1520
1826
  const tunnels = new Map((await listTunnels(cf)).map((t) => [t.id, t]));
1827
+ const issues = serviceIssuesByFqdn();
1521
1828
  const rows = await Promise.all(entries.map(async (e) => {
1522
1829
  const fqdn = entryFqdn(e);
1523
1830
  const gone = e.tunnelId ? !tunnels.has(e.tunnelId) : false;
1524
1831
  const svc = serviceState(fqdn);
1525
1832
  const state4 = await probeEntryState(e, gone);
1833
+ const issue = issues.get(fqdn);
1834
+ const svcLabel = svc === "none" ? "-" : svc;
1526
1835
  return {
1527
1836
  num: e.index ? String(e.index) : "-",
1528
1837
  url: `https://${fqdn}`,
1529
1838
  target: serviceUrl(e.proto, e.host ?? "localhost", e.port),
1530
1839
  protocol: e.protocol ?? "auto",
1531
1840
  state: state4,
1532
- service: svc === "none" ? "-" : svc,
1841
+ service: issue ? `${svcLabel} (${issue})` : svcLabel,
1533
1842
  pid: e.state === "running" && e.pid ? String(e.pid) : "-",
1534
- managed: true
1843
+ managed: true,
1844
+ serviceIssue: issue
1535
1845
  };
1536
1846
  }));
1537
1847
  if (opts.all) {
1538
- const { listCargoCnames } = await import("./dns-BNDEFUCM.js");
1539
- const { listZones: listZones3 } = await import("./zones-XNCXYCB4.js");
1848
+ const { listCargoCnames } = await import("./dns-VUPKRH46.js");
1849
+ const { listZones: listZones3 } = await import("./zones-CF4XFFZZ.js");
1540
1850
  const tracked = new Set(entries.map(entryFqdn));
1541
1851
  const unmanaged = [];
1542
1852
  for (const zone of await listZones3(cf.token)) {
@@ -1558,19 +1868,27 @@ async function listAll(cf, opts = {}) {
1558
1868
  }
1559
1869
 
1560
1870
  // src/core/watchdog.ts
1561
- import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
1871
+ import { readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
1562
1872
  var activeWatchdogs = /* @__PURE__ */ new Map();
1563
1873
  var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1874
+ var EXIT_RESTART_GAP_MS = 5e3;
1564
1875
  function resolveMetricsPort(logFile, fallback) {
1565
- if (existsSync6(logFile)) {
1876
+ if (existsSync7(logFile)) {
1566
1877
  try {
1567
- const port = extractMetricsPort(readFileSync5(logFile, "utf8"));
1878
+ const port = extractMetricsPort(readFileSync7(logFile, "utf8"));
1568
1879
  if (port) return port;
1569
1880
  } catch {
1570
1881
  }
1571
1882
  }
1572
1883
  return fallback;
1573
1884
  }
1885
+ async function freshMetricsPort() {
1886
+ try {
1887
+ return await pickFreePort();
1888
+ } catch {
1889
+ return void 0;
1890
+ }
1891
+ }
1574
1892
  async function verifyRemoteTunnel(cf, tunnelId) {
1575
1893
  try {
1576
1894
  await getTunnel(cf, tunnelId);
@@ -1580,25 +1898,39 @@ async function verifyRemoteTunnel(cf, tunnelId) {
1580
1898
  return true;
1581
1899
  }
1582
1900
  }
1583
- async function tier1Restart(ctx, state4) {
1584
- state4.lastRestartAt = Date.now();
1585
- state4.tier1Restarts++;
1586
- say.warn(`[watchdog] Health check failed for ${ctx.fqdn} -> restarting connector (Tier 1)...`);
1587
- await stopConnector({ pid: ctx.pid, bootId: currentBootId() });
1901
+ async function launchConnector(ctx) {
1902
+ const metricsPort = await freshMetricsPort();
1588
1903
  const conn = startConnector({
1589
1904
  bin: ctx.bin,
1590
1905
  token: ctx.token,
1591
1906
  detach: false,
1592
1907
  logFile: ctx.logFile,
1593
1908
  protocol: ctx.protocol,
1909
+ metricsPort,
1594
1910
  onExit: () => {
1595
- void onConnectorExited(ctx.fqdn);
1911
+ void notifyConnectorExited(ctx.fqdn);
1596
1912
  }
1597
1913
  });
1914
+ if (activeWatchdogs.get(ctx.fqdn)?.ctx !== ctx) {
1915
+ await stopConnector({ pid: conn.pid, bootId: currentBootId() });
1916
+ return;
1917
+ }
1598
1918
  ctx.pid = conn.pid;
1599
1919
  await sleep3(1e3);
1600
- ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1601
- await patchEntry(ctx.fqdn, { pid: conn.pid, metricsPort: ctx.metricsPort, bootId: currentBootId() });
1920
+ ctx.metricsPort = metricsPort ?? resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1921
+ await markConnectorRunning(ctx.fqdn, {
1922
+ pid: conn.pid,
1923
+ metricsPort: ctx.metricsPort,
1924
+ bootId: currentBootId(),
1925
+ tunnelId: ctx.tunnelId
1926
+ });
1927
+ }
1928
+ async function tier1Restart(ctx, state4) {
1929
+ state4.lastRestartAt = Date.now();
1930
+ state4.tier1Restarts++;
1931
+ say.warn(`[watchdog] Health check failed for ${ctx.fqdn} -> restarting connector (Tier 1)...`);
1932
+ await stopConnector({ pid: ctx.pid, bootId: currentBootId() });
1933
+ await launchConnector(ctx);
1602
1934
  state4.consecutiveFailures = 0;
1603
1935
  }
1604
1936
  async function tier2Reprovision(ctx, state4) {
@@ -1609,36 +1941,46 @@ async function tier2Reprovision(ctx, state4) {
1609
1941
  const result = await createTunnelSubdomain(ctx.cf, { ...ctx.item, force: true, yes: true });
1610
1942
  ctx.tunnelId = result.tunnelId;
1611
1943
  ctx.token = result.token;
1612
- const conn = startConnector({
1613
- bin: ctx.bin,
1614
- token: ctx.token,
1615
- detach: false,
1616
- logFile: ctx.logFile,
1617
- protocol: ctx.protocol,
1618
- onExit: () => {
1619
- void onConnectorExited(ctx.fqdn);
1620
- }
1621
- });
1622
- ctx.pid = conn.pid;
1623
- await sleep3(1e3);
1624
- ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1625
- await patchEntry(ctx.fqdn, { pid: conn.pid, metricsPort: ctx.metricsPort, bootId: currentBootId(), tunnelId: ctx.tunnelId });
1944
+ await launchConnector(ctx);
1626
1945
  state4.consecutiveFailures = 0;
1627
1946
  state4.tier1Restarts = 0;
1628
1947
  }
1629
- async function onConnectorExited(fqdn) {
1948
+ async function guardedRestart(fqdn, run2) {
1949
+ const item = activeWatchdogs.get(fqdn);
1950
+ if (!item || item.state.restarting) return;
1951
+ item.state.restarting = true;
1952
+ try {
1953
+ await run2();
1954
+ } catch (err) {
1955
+ say.warn(`[watchdog] Recovery for ${fqdn} failed: ${err.message} \u2014 retrying on the next check`);
1956
+ } finally {
1957
+ item.state.restarting = false;
1958
+ }
1959
+ }
1960
+ async function notifyConnectorExited(fqdn) {
1630
1961
  const item = activeWatchdogs.get(fqdn);
1631
1962
  if (!item) return;
1632
1963
  const { ctx, state: state4 } = item;
1633
- if (Date.now() - state4.lastRestartAt < 5e3) return;
1634
- await tier1Restart(ctx, state4);
1964
+ if (state4.restarting || Date.now() - state4.lastRestartAt < EXIT_RESTART_GAP_MS) return;
1965
+ const run2 = state4.tier1Restarts >= 3 ? tier2Reprovision : tier1Restart;
1966
+ await guardedRestart(fqdn, () => run2(ctx, state4));
1635
1967
  }
1636
1968
  function startTunnelWatchdog(ctx, opts = {}) {
1637
1969
  stopTunnelWatchdog(ctx.fqdn);
1638
1970
  const intervalMs = opts.intervalMs ?? 15e3;
1639
1971
  const cooldownMs = opts.cooldownMs ?? 5e3;
1640
- const state4 = { consecutiveFailures: 0, tier1Restarts: 0, tier2Restarts: 0, lastRestartAt: 0 };
1972
+ const state4 = { consecutiveFailures: 0, tier1Restarts: 0, tier2Restarts: 0, lastRestartAt: 0, restarting: false };
1973
+ let checking = false;
1641
1974
  const check = async () => {
1975
+ if (state4.restarting || checking) return;
1976
+ checking = true;
1977
+ try {
1978
+ await runCheck();
1979
+ } finally {
1980
+ checking = false;
1981
+ }
1982
+ };
1983
+ const runCheck = async () => {
1642
1984
  if (!ctx.metricsPort) ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1643
1985
  let healthy = false;
1644
1986
  if (ctx.metricsPort) {
@@ -1647,22 +1989,19 @@ function startTunnelWatchdog(ctx, opts = {}) {
1647
1989
  }
1648
1990
  if (healthy) {
1649
1991
  state4.consecutiveFailures = 0;
1992
+ state4.tier1Restarts = 0;
1650
1993
  return;
1651
1994
  }
1652
1995
  state4.consecutiveFailures++;
1653
1996
  if (state4.consecutiveFailures >= 3 && Date.now() - state4.lastRestartAt >= cooldownMs) {
1654
1997
  const remoteExists = await verifyRemoteTunnel(ctx.cf, ctx.tunnelId);
1655
- if (!remoteExists || state4.tier1Restarts >= 3) {
1656
- await tier2Reprovision(ctx, state4);
1657
- } else {
1658
- await tier1Restart(ctx, state4);
1659
- }
1998
+ const run2 = !remoteExists || state4.tier1Restarts >= 3 ? tier2Reprovision : tier1Restart;
1999
+ await guardedRestart(ctx.fqdn, () => run2(ctx, state4));
1660
2000
  }
1661
2001
  };
1662
2002
  const timer = setInterval(() => {
1663
- void check();
2003
+ check().catch((err) => say.debug(`[watchdog] check failed: ${err.message}`));
1664
2004
  }, intervalMs);
1665
- if (typeof timer.unref === "function") timer.unref();
1666
2005
  activeWatchdogs.set(ctx.fqdn, { timer, ctx, state: state4 });
1667
2006
  }
1668
2007
  function stopTunnelWatchdog(fqdn) {
@@ -1676,26 +2015,55 @@ function stopAllWatchdogs() {
1676
2015
  for (const [fqdn] of activeWatchdogs) stopTunnelWatchdog(fqdn);
1677
2016
  }
1678
2017
 
2018
+ // src/core/installed-version.ts
2019
+ import { readFileSync as readFileSync8, realpathSync as realpathSync2 } from "fs";
2020
+ import { dirname as dirname3, join as join8 } from "path";
2021
+ function readInstalledVersion() {
2022
+ try {
2023
+ const script = realpathSync2(process.argv[1] ?? "");
2024
+ const pkg2 = JSON.parse(readFileSync8(join8(dirname3(script), "..", "package.json"), "utf8"));
2025
+ return typeof pkg2.version === "string" ? pkg2.version : void 0;
2026
+ } catch {
2027
+ return void 0;
2028
+ }
2029
+ }
2030
+
1679
2031
  // src/core/up-runner.ts
1680
2032
  var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
2033
+ var UPGRADE_RESTART_EXIT_CODE = 75;
2034
+ var VERSION_CHECK_INTERVAL_MS = 6e4;
2035
+ var LOCK_WAIT_MS = 3e4;
2036
+ var MAX_CREATE_BACKOFF_MS = 6e4;
1681
2037
  function logFileFor(fqdn) {
1682
- return join7(logDir, `${serviceSlug(fqdn)}.log`);
2038
+ return join9(logDir, `${serviceSlug(fqdn)}.log`);
2039
+ }
2040
+ function knownFqdn(item) {
2041
+ if (item.hostname) return item.hostname;
2042
+ return item.name !== void 0 && item.zone ? fqdnFor(item.name, item.zone) : void 0;
1683
2043
  }
1684
2044
  async function createWithBootRetry(cf, item, keepAlive) {
1685
- const deadline = Date.now() + (keepAlive ? 3e4 : 0);
2045
+ if (!keepAlive) return createTunnelSubdomain(cf, item);
1686
2046
  for (let attempt = 0; ; attempt++) {
1687
2047
  try {
1688
- return await createTunnelSubdomain(cf, item);
2048
+ return await createTunnelSubdomain(cf, { ...item, reuse: true });
1689
2049
  } catch (err) {
1690
- if (!keepAlive || Date.now() >= deadline) throw err;
1691
- say.debug(`[boot-retry] Waiting for network to create ${item.name ?? "tunnel"}...`);
1692
- await sleep4(Math.min(2 ** attempt * 1e3, 5e3));
2050
+ const wait = Math.min(2 ** attempt * 1e3, MAX_CREATE_BACKOFF_MS);
2051
+ say.warn(`Could not create ${item.name ?? "tunnel"} (${err.message}) \u2014 retrying in ${wait / 1e3}s`);
2052
+ await sleep4(wait);
1693
2053
  }
1694
2054
  }
1695
2055
  }
2056
+ async function freshMetricsPort2() {
2057
+ try {
2058
+ return await pickFreePort();
2059
+ } catch {
2060
+ return void 0;
2061
+ }
2062
+ }
1696
2063
  async function startTunnels(cf, bin, items, opts = {}) {
1697
2064
  const started = [];
1698
2065
  let tornDown = false;
2066
+ const supervised = !!opts.keepAlive && !opts.detach;
1699
2067
  const teardownAll = async (code) => {
1700
2068
  if (tornDown) return;
1701
2069
  tornDown = true;
@@ -1703,7 +2071,8 @@ async function startTunnels(cf, bin, items, opts = {}) {
1703
2071
  try {
1704
2072
  for (const s of started) {
1705
2073
  if (opts.keepAlive) {
1706
- await stopConnector({ pid: s.pid, bootId: currentBootId() });
2074
+ const current = getEntry(s.fqdn);
2075
+ await stopConnector(current?.pid ? current : { pid: s.pid, bootId: currentBootId() });
1707
2076
  } else {
1708
2077
  try {
1709
2078
  await removeTunnelSubdomain(cf, s.fqdn, { force: true, quiet: true });
@@ -1718,6 +2087,12 @@ async function startTunnels(cf, bin, items, opts = {}) {
1718
2087
  process.exit(code);
1719
2088
  }
1720
2089
  };
2090
+ for (const item of items) {
2091
+ const fqdn = knownFqdn(item);
2092
+ if (!fqdn) continue;
2093
+ if (opts.detach) await assertHostnameFree(fqdn);
2094
+ else await acquireHostnameLock(fqdn, { waitMs: opts.keepAlive ? LOCK_WAIT_MS : 0 });
2095
+ }
1721
2096
  const spin = clack2.spinner();
1722
2097
  spin.start(items.length > 1 ? "Creating tunnels\u2026" : "Creating tunnel\u2026");
1723
2098
  for (const item of items) {
@@ -1725,28 +2100,35 @@ async function startTunnels(cf, bin, items, opts = {}) {
1725
2100
  const result = await createWithBootRetry(cf, item, opts.keepAlive);
1726
2101
  const fqdn = result.host.hostname;
1727
2102
  const logFile = logFileFor(fqdn);
2103
+ const knownPort = await freshMetricsPort2();
1728
2104
  const conn = startConnector({
1729
2105
  bin,
1730
2106
  token: result.token,
1731
2107
  detach: !!opts.detach,
1732
2108
  logFile,
1733
2109
  protocol: opts.protocol,
2110
+ metricsPort: knownPort,
1734
2111
  onExit: opts.detach ? void 0 : (code) => {
1735
- if (!tornDown && !opts.keepAlive) {
1736
- say.warn(`Connector for ${fqdn} exited.`);
1737
- void teardownAll(code ?? 1);
2112
+ if (tornDown) return;
2113
+ if (opts.keepAlive) {
2114
+ void notifyConnectorExited(fqdn);
2115
+ return;
1738
2116
  }
2117
+ say.warn(`Connector for ${fqdn} exited.`);
2118
+ void teardownAll(code ?? 1);
1739
2119
  }
1740
2120
  });
1741
- await sleep4(600);
1742
- let metricsPort;
1743
- if (existsSync7(logFile)) {
1744
- try {
1745
- metricsPort = extractMetricsPort(readFileSync6(logFile, "utf8"));
1746
- } catch {
2121
+ let metricsPort = knownPort;
2122
+ if (!metricsPort) {
2123
+ await sleep4(600);
2124
+ if (existsSync8(logFile)) {
2125
+ try {
2126
+ metricsPort = extractMetricsPort(readFileSync9(logFile, "utf8"));
2127
+ } catch {
2128
+ }
1747
2129
  }
1748
2130
  }
1749
- await patchEntry(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile, metricsPort, protocol: opts.protocol });
2131
+ await markConnectorRunning(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile, metricsPort, protocol: opts.protocol });
1750
2132
  started.push({
1751
2133
  fqdn,
1752
2134
  subdomain: result.host.subdomain,
@@ -1754,7 +2136,7 @@ async function startTunnels(cf, bin, items, opts = {}) {
1754
2136
  target: serviceUrl(item.proto, item.host ?? "localhost", item.port),
1755
2137
  pid: conn.pid
1756
2138
  });
1757
- if (!opts.detach) {
2139
+ if (supervised) {
1758
2140
  startTunnelWatchdog({
1759
2141
  cf,
1760
2142
  bin,
@@ -1779,6 +2161,7 @@ async function startTunnels(cf, bin, items, opts = {}) {
1779
2161
  for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"]) {
1780
2162
  process.on(sig, () => void teardownAll(0));
1781
2163
  }
2164
+ if (supervised) watchForUpgrade(() => void teardownAll(UPGRADE_RESTART_EXIT_CODE));
1782
2165
  spin.message("Connecting to the Cloudflare edge\u2026");
1783
2166
  const healths = await Promise.all(started.map((s) => waitHealthy(cf, s.tunnelId, { timeoutMs: 3e4 })));
1784
2167
  const live = healths.filter((h) => h === "healthy").length;
@@ -1787,6 +2170,20 @@ async function startTunnels(cf, bin, items, opts = {}) {
1787
2170
  clack2.note(lines.join("\n"), `${live}/${started.length} live`);
1788
2171
  say.dim(opts.keepAlive ? "Service running with auto-heal watchdog." : "Ctrl-C stops and releases them.");
1789
2172
  }
2173
+ function watchForUpgrade(restart) {
2174
+ if (process.platform === "win32") return;
2175
+ const startedWith = readInstalledVersion();
2176
+ if (!startedWith) return;
2177
+ const timer = setInterval(() => {
2178
+ const now = readInstalledVersion();
2179
+ if (now && now !== startedWith) {
2180
+ clearInterval(timer);
2181
+ say.warn(`cloudtunnel ${startedWith} \u2192 ${now} installed \u2014 restarting onto the new version\u2026`);
2182
+ restart();
2183
+ }
2184
+ }, VERSION_CHECK_INTERVAL_MS);
2185
+ timer.unref();
2186
+ }
1790
2187
 
1791
2188
  // src/core/resolve-domain.ts
1792
2189
  async function resolveDomain(cf, opts, creds) {
@@ -1799,12 +2196,6 @@ async function resolveDomain(cf, opts, creds) {
1799
2196
  throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
1800
2197
  }
1801
2198
 
1802
- // src/core/transport-protocol.ts
1803
- function parseTransportProtocol(value) {
1804
- if (value === "auto" || value === "http2" || value === "quic") return value;
1805
- throw new CliError(`Invalid protocol "${value}".`, { hint: "use auto, http2, or quic" });
1806
- }
1807
-
1808
2199
  // src/commands/up.ts
1809
2200
  function promptOrExit(value) {
1810
2201
  if (clack3.isCancel(value)) {
@@ -1940,6 +2331,8 @@ function registerLs(program) {
1940
2331
  ["#", "URL", "TARGET", "PROTOCOL", "STATE", "SERVICE", "PID"],
1941
2332
  rows.map((r) => [r.num, r.url, r.target, r.protocol, r.state, r.service, r.pid])
1942
2333
  );
2334
+ const needRepair = rows.filter((r) => r.serviceIssue).length;
2335
+ if (needRepair) say.warn(`${needRepair} boot service(s) need repair \u2192 cloudtunnel service repair`);
1943
2336
  });
1944
2337
  }
1945
2338
 
@@ -1998,19 +2391,19 @@ function registerDelete(program) {
1998
2391
  }
1999
2392
 
2000
2393
  // src/commands/logs.ts
2001
- import { closeSync, existsSync as existsSync8, openSync as openSync2, readFileSync as readFileSync7, readSync, statSync, watch } from "fs";
2394
+ import { closeSync, existsSync as existsSync9, openSync as openSync2, readFileSync as readFileSync10, readSync, statSync as statSync2, watch } from "fs";
2002
2395
  function printTail(file, n) {
2003
- const lines = readFileSync7(file, "utf8").split("\n");
2396
+ const lines = readFileSync10(file, "utf8").split("\n");
2004
2397
  const tail = lines.slice(-n).join("\n");
2005
2398
  process.stdout.write(tail.endsWith("\n") ? tail : `${tail}
2006
2399
  `);
2007
- return statSync(file).size;
2400
+ return statSync2(file).size;
2008
2401
  }
2009
2402
  function follow(file, fromPos) {
2010
2403
  let pos = fromPos;
2011
2404
  say.dim("\u2014 following (Ctrl-C to stop) \u2014");
2012
2405
  const watcher = watch(file, () => {
2013
- const size = statSync(file).size;
2406
+ const size = statSync2(file).size;
2014
2407
  if (size < pos) {
2015
2408
  pos = 0;
2016
2409
  return;
@@ -2032,7 +2425,7 @@ function follow(file, fromPos) {
2032
2425
  function registerLogs(program) {
2033
2426
  program.command("logs").argument("<target>", "subdomain name / hostname / id / #").description("Show the connector log for a subdomain (use -f to follow)").option("-f, --follow", "keep printing new log lines (like tail -f)").option("-n, --lines <n>", "number of lines to show", "50").action((name, opts) => {
2034
2427
  const { fqdn, entry } = resolveTarget(name);
2035
- if (!entry?.logFile || !existsSync8(entry.logFile)) {
2428
+ if (!entry?.logFile || !existsSync9(entry.logFile)) {
2036
2429
  throw new CliError(`No logs for ${fqdn} yet.`, { hint: "start it with `cloudtunnel up` or `cloudtunnel run`" });
2037
2430
  }
2038
2431
  const n = Math.max(1, Number(opts.lines) || 50);
@@ -2044,7 +2437,7 @@ function registerLogs(program) {
2044
2437
  // src/commands/relay.ts
2045
2438
  import { openSync as openSync3 } from "fs";
2046
2439
  import { spawn as spawn3 } from "child_process";
2047
- import { join as join8 } from "path";
2440
+ import { join as join10 } from "path";
2048
2441
  import pc from "picocolors";
2049
2442
 
2050
2443
  // src/core/api-proxy-server.ts
@@ -2151,7 +2544,7 @@ function spawnDetachedRelay(sub, domain) {
2151
2544
  const script = process.argv[1];
2152
2545
  if (!script) throw new CliError("Cannot resolve the cloudtunnel executable path.");
2153
2546
  ensureDirs();
2154
- const logFile = join8(logDir, `relay-${sub === "@" ? "root" : sub}.log`);
2547
+ const logFile = join10(logDir, `relay-${sub === "@" ? "root" : sub}.log`);
2155
2548
  const fd = openSync3(logFile, "a", 384);
2156
2549
  const args = [script, "relay", sub, "-d", domain, "-f", "-y"];
2157
2550
  const child = spawn3(process.execPath, args, { detached: true, stdio: ["ignore", fd, fd] });
@@ -2209,11 +2602,68 @@ async function runRelay(sub, opts) {
2209
2602
  force: opts.force,
2210
2603
  yes: opts.yes
2211
2604
  };
2212
- await startTunnels(cf, bin, [item], {});
2605
+ await startTunnels(cf, bin, [item], { keepAlive: opts.keepAlive });
2213
2606
  printRelayReady(fqdn, secret, "foreground");
2214
2607
  }
2215
2608
  function registerRelay(program) {
2216
- 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));
2609
+ 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("--keep-alive", "supervised mode with auto-heal watchdog (used by the boot service)").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));
2610
+ }
2611
+
2612
+ // src/commands/service.ts
2613
+ function describeAction(a) {
2614
+ const parts = [];
2615
+ if (a.reinstall) parts.push(`reinstall + restart (${a.reinstall.reason})`);
2616
+ if (a.remove.length) parts.push(`remove duplicate ${a.remove.join(", ")}`);
2617
+ return `${a.fqdn}: ${parts.join(" \xB7 ")}`;
2618
+ }
2619
+ async function repairWithPrompt(actions, opts = {}) {
2620
+ for (const a of actions) say.dim(` ${describeAction(a)}`);
2621
+ if (opts.dryRun) return false;
2622
+ const needsSudo = process.platform === "linux" ? " (uses sudo)" : "";
2623
+ if (!opts.yes && !await confirm(`Repair ${actions.length} boot service(s) now?${needsSudo}`)) return false;
2624
+ const res = runRepair(actions);
2625
+ for (const fqdn of res.fixed) say.ok(`Repaired ${fqdn}`);
2626
+ for (const f of res.failed) say.warn(`Could not repair ${f.fqdn}: ${f.error}`);
2627
+ if (res.failed.length) {
2628
+ throw new CliError(`${res.failed.length} boot service(s) could not be repaired.`, { hint: "re-run `cloudtunnel service repair`" });
2629
+ }
2630
+ return res.fixed.length > 0;
2631
+ }
2632
+ function registerService(program) {
2633
+ const service = program.command("service").description("Manage boot services (installed with `up --service`)");
2634
+ service.command("repair").description("Regenerate + restart boot services that are outdated, broken, stopped, or duplicated").option("-y, --yes", "don't ask for confirmation").option("--dry-run", "show what would be repaired without doing it").action(async (opts) => {
2635
+ assertServiceSupported();
2636
+ const services = inventoryServices();
2637
+ const actions = planRepair(services, { restartRunning: process.platform === "win32" });
2638
+ if (actions.length === 0) {
2639
+ say.ok(`All ${services.length} boot service(s) are up to date and running.`);
2640
+ return;
2641
+ }
2642
+ say.info(`${actions.length} boot service(s) need repair:`);
2643
+ await repairWithPrompt(actions, opts);
2644
+ });
2645
+ }
2646
+
2647
+ // src/core/service-upgrade-check.ts
2648
+ var INFO_ARGS = /* @__PURE__ */ new Set(["-h", "--help", "-v", "--version", "help"]);
2649
+ function remember(version) {
2650
+ saveConfig({ ...loadConfig(), serviceCheckVersion: version });
2651
+ }
2652
+ async function maybeRepairServicesAfterUpgrade(version, argv) {
2653
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return;
2654
+ const rest = argv.slice(2);
2655
+ if (rest[0] === "service" || rest[0] === "delete" || rest.some((a) => INFO_ARGS.has(a))) return;
2656
+ if (loadConfig().serviceCheckVersion === version) return;
2657
+ try {
2658
+ const actions = planRepair(inventoryServices(), { restartRunning: process.platform === "win32" });
2659
+ if (actions.length) {
2660
+ say.warn(`cloudtunnel ${version}: ${actions.length} boot service(s) need repair to stay up:`);
2661
+ await repairWithPrompt(actions);
2662
+ }
2663
+ } catch (err) {
2664
+ say.warn(`Boot-service check failed: ${err.message} \u2014 run \`cloudtunnel service repair\``);
2665
+ }
2666
+ remember(version);
2217
2667
  }
2218
2668
 
2219
2669
  // src/index.ts
@@ -2233,7 +2683,7 @@ function buildProgram() {
2233
2683
  ""
2234
2684
  ].join("\n")
2235
2685
  );
2236
- for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs, registerRelay]) {
2686
+ for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs, registerRelay, registerService]) {
2237
2687
  register(program);
2238
2688
  }
2239
2689
  return program;
@@ -2247,6 +2697,7 @@ function shouldMigrate(argv) {
2247
2697
  async function main() {
2248
2698
  configureProxy();
2249
2699
  if (shouldMigrate(process.argv)) await migrateLegacyProfiles();
2700
+ await maybeRepairServicesAfterUpgrade(pkg.version, process.argv);
2250
2701
  const program = buildProgram();
2251
2702
  try {
2252
2703
  await program.parseAsync(process.argv);