harnery 0.42.0 → 0.43.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.
Files changed (77) hide show
  1. package/dist/commander.d.ts +16 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +18 -0
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +54 -4
  6. package/dist/commands/tunnel.d.ts +65 -0
  7. package/dist/commands/tunnel.d.ts.map +1 -1
  8. package/dist/commands/tunnel.js +290 -101
  9. package/dist/commands/web-fetch.d.ts +3 -2
  10. package/dist/commands/web-fetch.d.ts.map +1 -1
  11. package/dist/commands/web-fetch.js +25 -19
  12. package/dist/commands/web.d.ts +7 -1
  13. package/dist/commands/web.d.ts.map +1 -1
  14. package/dist/commands/web.js +9 -3
  15. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  16. package/dist/core/agents/render/prompt-context.js +11 -5
  17. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  18. package/dist/core/agents/render/session-context.js +57 -13
  19. package/dist/core/agents/state/names.d.ts +43 -3
  20. package/dist/core/agents/state/names.d.ts.map +1 -1
  21. package/dist/core/agents/state/names.js +87 -3
  22. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  23. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  24. package/dist/core/hooks/cli.js +87 -18
  25. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  26. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  27. package/dist/core/hooks/fork-lineage.js +62 -0
  28. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  29. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  30. package/dist/core/hooks/resolve/transcript.js +84 -0
  31. package/dist/core/hooks/shell-rm.d.ts +2 -0
  32. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  33. package/dist/core/hooks/shell-rm.js +105 -0
  34. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  35. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  36. package/dist/core/hooks/shell-tokens.js +97 -0
  37. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  38. package/dist/core/hooks/shell-waiter.js +3 -93
  39. package/dist/core/semantic/contract.d.ts +5 -5
  40. package/dist/core/storage/process-log.d.ts +12 -0
  41. package/dist/core/storage/process-log.d.ts.map +1 -1
  42. package/dist/core/storage/process-log.js +10 -2
  43. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  44. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  45. package/dist/lib/tunnel/allowlist.js +204 -0
  46. package/dist/lib/tunnel/current-address.d.ts +41 -0
  47. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  48. package/dist/lib/tunnel/current-address.js +123 -0
  49. package/dist/lib/tunnel/gate.js +13 -6
  50. package/dist/lib/tunnel/processes.d.ts +39 -0
  51. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  52. package/dist/lib/tunnel/processes.js +121 -0
  53. package/dist/lib/tunnel/state.d.ts +28 -3
  54. package/dist/lib/tunnel/state.d.ts.map +1 -1
  55. package/dist/lib/tunnel/state.js +98 -27
  56. package/package.json +2 -1
  57. package/src/commander.ts +32 -0
  58. package/src/commands/agents.ts +97 -11
  59. package/src/commands/tunnel.ts +394 -116
  60. package/src/commands/web-fetch.ts +30 -19
  61. package/src/commands/web.ts +9 -3
  62. package/src/core/agents/render/prompt-context.ts +25 -8
  63. package/src/core/agents/render/session-context.ts +83 -16
  64. package/src/core/agents/state/names.ts +117 -6
  65. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  66. package/src/core/hooks/cli.ts +106 -17
  67. package/src/core/hooks/fork-lineage.ts +82 -0
  68. package/src/core/hooks/resolve/transcript.ts +88 -0
  69. package/src/core/hooks/shell-rm.ts +103 -0
  70. package/src/core/hooks/shell-tokens.ts +96 -0
  71. package/src/core/hooks/shell-waiter.ts +4 -91
  72. package/src/core/storage/process-log.ts +17 -2
  73. package/src/lib/tunnel/allowlist.ts +208 -0
  74. package/src/lib/tunnel/current-address.ts +158 -0
  75. package/src/lib/tunnel/gate.ts +16 -8
  76. package/src/lib/tunnel/processes.ts +149 -0
  77. package/src/lib/tunnel/state.ts +123 -27
@@ -14,10 +14,23 @@ import {
14
14
  } from "../core/servers/index.ts";
15
15
  import {
16
16
  processLogDestination,
17
+ runProcessLogWorker,
17
18
  runRotatingProcessSync,
18
19
  spawnRotatingProcess,
19
20
  } from "../core/storage/process-log.ts";
21
+ import { normalizeAllowEntry } from "../lib/tunnel/allowlist.ts";
22
+ import {
23
+ type DetectOptions,
24
+ detectPublicAddresses,
25
+ planCurrentAddresses,
26
+ } from "../lib/tunnel/current-address.ts";
20
27
  import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.ts";
28
+ import {
29
+ connectProbe,
30
+ findPidsByCommandLine,
31
+ listeningPorts,
32
+ portListening,
33
+ } from "../lib/tunnel/processes.ts";
21
34
  import {
22
35
  clearState,
23
36
  DEFAULT_INSTANCE,
@@ -46,9 +59,11 @@ import {
46
59
  * Every provider also passes through a path scope: `up` requires at least one
47
60
  * `--allow-path <prefix>`, and the gate refuses any request outside it.
48
61
  *
49
- * State + config persisted under `.cache/tunnel/`. cloudflared auto-installs
50
- * to ~/.local/bin/ on first run (Linux only; macOS users `brew install`);
51
- * Tailscale requires an installed/authenticated `tailscale` CLI.
62
+ * State + config persisted under `.cache/tunnel/` (or `$HARNERY_TUNNEL_DIR`).
63
+ * cloudflared comes from `$HARNERY_CLOUDFLARED` / the config's `cloudflared_path`
64
+ * when set, else PATH, else auto-installs to ~/.local/bin/ on Linux (macOS and
65
+ * Windows hosts install it themselves); Tailscale requires an installed and
66
+ * authenticated `tailscale` CLI.
52
67
  */
53
68
 
54
69
  const DEFAULT_TARGET = "127.0.0.1:8001";
@@ -220,19 +235,100 @@ function gateScriptPath(): string {
220
235
  return resolve(import.meta.dirname, "..", "lib", "tunnel", "gate.ts");
221
236
  }
222
237
 
238
+ /** The gate worker's `--name`/`--port` marker, also what the stray sweep matches. */
239
+ const GATE_SUBCOMMAND = ["tunnel", "gate"] as const;
240
+
241
+ export interface GateLaunch {
242
+ command: string;
243
+ /** Arguments before the gate's own `--name` and `--port`. */
244
+ arguments: string[];
245
+ }
246
+
247
+ export interface GateLaunchInputs {
248
+ /** Absolute path of `lib/tunnel/gate.ts` beside this file, whether or not it exists. */
249
+ gateScript: string;
250
+ gateScriptExists: boolean;
251
+ /** True when this process runs on Bun (so `execPath` is a Bun binary). */
252
+ underBun: boolean;
253
+ execPath: string;
254
+ /** The script this process was started with (`argv[1]`): the host CLI's entry or bundle. */
255
+ entryScript: string | undefined;
256
+ entryScriptExists: boolean;
257
+ bunOnPath: boolean;
258
+ }
259
+
260
+ /**
261
+ * How to start the gate worker, which is a `Bun.serve` process (HTTP and
262
+ * WebSocket reverse proxy):
263
+ * 1. From a source or package checkout, `gate.ts` sits beside this module and
264
+ * runs directly.
265
+ * 2. Inside a single-file bundle there is no such file. The CLI then
266
+ * re-executes itself with the hidden `tunnel gate` task, which loads the
267
+ * gate in-process. This uses the running Bun binary, so Bun need not be
268
+ * on PATH.
269
+ * Null means neither works (a Node host with no Bun, or an unknown entry).
270
+ */
271
+ export function resolveGateLaunch(i: GateLaunchInputs): GateLaunch | null {
272
+ if (i.gateScriptExists && (i.underBun || i.bunOnPath)) {
273
+ return {
274
+ command: i.underBun ? i.execPath : "bun",
275
+ arguments: ["run", i.gateScript],
276
+ };
277
+ }
278
+ if (i.underBun && i.entryScript && i.entryScriptExists) {
279
+ return { command: i.execPath, arguments: [i.entryScript, ...GATE_SUBCOMMAND] };
280
+ }
281
+ return null;
282
+ }
283
+
223
284
  /**
224
- * `harn tunnel` is the one command that hard-requires Bun: the gate worker is a
225
- * `Bun.serve` process (HTTP + WebSocket reverse proxy), spawned as `bun run
226
- * gate.ts`. Everything else in harnery runs on Node, but this can't until the
227
- * gate is ported off `Bun.serve` (node:http + `ws`). Detect Bun up front so the
228
- * failure is a clear message rather than an opaque ENOENT from the gate spawn.
285
+ * How to start the log wrapper that rotates each process's output. Beside a
286
+ * source or package checkout the default (this package's own wrapper file)
287
+ * works; inside a single-file bundle the CLI re-executes itself with the hidden
288
+ * `tunnel log-worker` task instead.
229
289
  */
230
- function bunAvailable(): boolean {
231
- return spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0;
290
+ export function resolveWorkerLaunch(i: GateLaunchInputs): GateLaunch | undefined {
291
+ if (i.gateScriptExists) return undefined;
292
+ if (i.underBun && i.entryScript && i.entryScriptExists) {
293
+ return { command: i.execPath, arguments: [i.entryScript, "tunnel", "log-worker"] };
294
+ }
295
+ return undefined;
232
296
  }
233
297
 
298
+ function currentLaunchInputs(): GateLaunchInputs {
299
+ const gateScript = gateScriptPath();
300
+ const entryScript = process.argv[1] ? resolve(process.argv[1]) : undefined;
301
+ const underBun = typeof process.versions.bun === "string";
302
+ const gateScriptExists = existsSync(gateScript);
303
+ return {
304
+ gateScript,
305
+ gateScriptExists,
306
+ underBun,
307
+ execPath: process.execPath,
308
+ entryScript,
309
+ entryScriptExists: entryScript ? existsSync(entryScript) : false,
310
+ bunOnPath: !underBun && gateScriptExists && bunOnPath(),
311
+ };
312
+ }
313
+
314
+ function currentGateLaunch(): GateLaunch | null {
315
+ return resolveGateLaunch(currentLaunchInputs());
316
+ }
317
+
318
+ function currentWorkerLaunch(): GateLaunch | undefined {
319
+ return resolveWorkerLaunch(currentLaunchInputs());
320
+ }
321
+
322
+ function bunOnPath(): boolean {
323
+ return spawnSync("bun", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0;
324
+ }
325
+
326
+ const requiresBunMessage = () =>
327
+ `${resolveBinName()} tunnel requires Bun: the gate worker is a Bun.serve process. ` +
328
+ "Install Bun (https://bun.sh) and re-run. (Every other command runs on Node.)";
329
+
234
330
  function tailscaleAvailable(): boolean {
235
- return spawnSync("tailscale", ["version"], { stdio: "ignore" }).status === 0;
331
+ return spawnSync("tailscale", ["version"], { stdio: "ignore", windowsHide: true }).status === 0;
236
332
  }
237
333
 
238
334
  function sleep(ms: number): Promise<void> {
@@ -240,17 +336,15 @@ function sleep(ms: number): Promise<void> {
240
336
  }
241
337
 
242
338
  /**
243
- * Kill every process whose command line matches `pattern` (via `pgrep -f`),
244
- * skipping our own PID and any already-killed. Returns the count killed. Used
245
- * as a fallback so orphaned gate/cloudflared processes get cleaned even when
246
- * the state file was lost (which otherwise left them squatting on the port).
339
+ * Kill every process whose command line matches `pattern` (an extended regular
340
+ * expression), skipping our own PID and any already-killed. Returns the count
341
+ * killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
342
+ * even when the state file was lost (which otherwise left them squatting on the
343
+ * port).
247
344
  */
248
345
  function killByPattern(pattern: string, alreadyKilled: Set<number>): number {
249
- const r = spawnSync("pgrep", ["-f", pattern], { encoding: "utf-8" });
250
- if (r.status !== 0 || typeof r.stdout !== "string") return 0;
251
346
  let killed = 0;
252
- for (const line of r.stdout.split("\n")) {
253
- const pid = Number(line.trim());
347
+ for (const pid of findPidsByCommandLine(pattern)) {
254
348
  if (!pid || pid === process.pid || alreadyKilled.has(pid)) continue;
255
349
  try {
256
350
  process.kill(pid);
@@ -263,63 +357,49 @@ function killByPattern(pattern: string, alreadyKilled: Set<number>): number {
263
357
  return killed;
264
358
  }
265
359
 
360
+ /**
361
+ * Command-line pattern for ONE instance's gate, identified by its port. Matches
362
+ * both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
363
+ * re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
364
+ * guarded with `( |$)` so port 9001 doesn't match 90011.
365
+ */
366
+ function gatePattern(gatePort: number): string {
367
+ return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
368
+ }
369
+
266
370
  /**
267
371
  * Sweep stray gate + cloudflared processes for ONE instance, identified by its
268
372
  * gate port. Both signatures are port-scoped so tearing down one tunnel never
269
373
  * touches another:
270
- * - gate: `gate.ts ... --port <port>` (the port is on the gate's argv)
374
+ * - gate: see gatePattern (the port is on the gate's argv)
271
375
  * - cloudflared: `--url http://localhost:<port>` (order-independent, so it
272
376
  * matches regardless of the `--protocol http2` flag we also pass).
273
- * Port boundary is guarded with `( |$)` so port 9001 doesn't match 90011.
274
377
  */
275
378
  function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
276
379
  return (
277
- killByPattern(`gate\\.ts.*--port ${gatePort}( |$)`, alreadyKilled) +
380
+ killByPattern(gatePattern(gatePort), alreadyKilled) +
278
381
  killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled)
279
382
  );
280
383
  }
281
384
 
282
- /**
283
- * Ports currently bound by a LISTEN socket (best-effort, platform-dependent).
284
- *
285
- * `ss` (iproute2) is Linux-only. Without the lsof fallback this returns an empty
286
- * set on macOS/BSD. That does not fail loudly. It reports every port as free, so
287
- * `allocateGatePort` can hand out an occupied port and the reload port-release
288
- * check becomes inert.
289
- */
290
- function listeningPorts(): Set<number> {
291
- const ports = new Set<number>();
292
- const ss = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
293
- if (ss.status === 0 && typeof ss.stdout === "string") {
294
- for (const m of ss.stdout.matchAll(/:(\d+)\s/g)) ports.add(Number(m[1]));
295
- return ports;
296
- }
297
- // Rows look like: `bun 123 user 4u IPv4 0x… 0t0 TCP 127.0.0.1:58055 (LISTEN)`.
298
- // Status can be nonzero while stdout still holds usable rows (lsof reports a
299
- // failure when any single process is unreadable), so judge it on the output.
300
- const lsof = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf-8" });
301
- if (typeof lsof.stdout === "string") {
302
- for (const m of lsof.stdout.matchAll(/:(\d+) \(LISTEN\)$/gm)) ports.add(Number(m[1]));
303
- }
304
- return ports;
305
- }
306
-
307
385
  /**
308
386
  * Pick a gate port for a new instance. An explicit `--gate-port` is honored
309
387
  * (and rejected if it's already taken); otherwise scan upward from 9001 for the
310
388
  * first port that's neither held by a live instance nor currently listening.
311
389
  */
312
- function allocateGatePort(preferred: number | undefined): number {
390
+ async function allocateGatePort(preferred: number | undefined): Promise<number> {
313
391
  const used = new Set<number>(
314
392
  listStates()
315
393
  .filter((s) => isProcessAlive(s.gate_pid))
316
394
  .map((s) => s.gate_port),
317
395
  );
396
+ // No discovery tool (null) is not "every port free": probe candidates by connecting.
318
397
  const listening = listeningPorts();
319
- const taken = (p: number) => used.has(p) || listening.has(p);
398
+ const taken = async (p: number) =>
399
+ used.has(p) || (listening ? listening.has(p) : await connectProbe(p));
320
400
 
321
401
  if (preferred !== undefined) {
322
- if (taken(preferred)) {
402
+ if (await taken(preferred)) {
323
403
  emit.error({
324
404
  code: "tunnel_port_taken",
325
405
  message: `Gate port ${preferred} is already in use. Omit --gate-port to auto-allocate, or pick a free one.`,
@@ -329,7 +409,7 @@ function allocateGatePort(preferred: number | undefined): number {
329
409
  return preferred;
330
410
  }
331
411
  for (let p = DEFAULT_GATE_PORT; p <= MAX_GATE_PORT; p++) {
332
- if (!taken(p)) return p;
412
+ if (!(await taken(p))) return p;
333
413
  }
334
414
  emit.error({
335
415
  code: "tunnel_no_free_port",
@@ -507,13 +587,14 @@ interface GateSpawnOpts {
507
587
  * is passed as an env var, which the gate snapshots at module load and never
508
588
  * re-reads — that snapshot is precisely why `reload` has to exist.
509
589
  */
510
- function spawnGate(o: GateSpawnOpts): ChildProcess {
590
+ function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
511
591
  // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
512
- // process distinguishable per-instance in `pgrep -f` (see sweepStrays).
592
+ // process distinguishable per-instance in a command-line search (see sweepStrays).
513
593
  const gateProc = spawnRotatingProcess({
514
594
  path: o.gateLogPath,
515
- command: "bun",
516
- arguments: ["run", gateScriptPath(), "--name", o.name, "--port", String(o.gatePort)],
595
+ command: launch.command,
596
+ arguments: [...launch.arguments, "--name", o.name, "--port", String(o.gatePort)],
597
+ worker: currentWorkerLaunch(),
517
598
  env: {
518
599
  ...process.env,
519
600
  HARNERY_TUNNEL_ALLOW: o.allowedIps.join(","),
@@ -530,13 +611,9 @@ function spawnGate(o: GateSpawnOpts): ChildProcess {
530
611
  }
531
612
 
532
613
  async function up(opts: UpOpts): Promise<void> {
533
- if (!bunAvailable()) {
534
- emit.error({
535
- code: "tunnel_requires_bun",
536
- message:
537
- "harn tunnel requires Bun: the gate worker is a Bun.serve process. " +
538
- "Install Bun (https://bun.sh) and re-run. (Every other harn command runs on Node.)",
539
- });
614
+ const launch = currentGateLaunch();
615
+ if (!launch) {
616
+ emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
540
617
  process.exit(1);
541
618
  }
542
619
  const name = resolveName(opts.name);
@@ -581,7 +658,7 @@ async function up(opts: UpOpts): Promise<void> {
581
658
 
582
659
  // Allocate the gate port (after clearing dead state so its old port frees up
583
660
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
584
- const gatePort = allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
661
+ const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
585
662
 
586
663
  // Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
587
664
  // a prior crashed or state-cleared run so the gate port is free before we bind.
@@ -591,12 +668,23 @@ async function up(opts: UpOpts): Promise<void> {
591
668
  if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
592
669
  emit.error({
593
670
  code: "tunnel_allowlist_empty",
594
- message: "Allowlist is empty; refusing to start. Add an IP first: harn tunnel allow add <ip>",
671
+ message: `Allowlist is empty; refusing to start. Add an IP first: ${resolveBinName()} tunnel allow add <ip> (or --current)`,
595
672
  });
596
673
  process.exit(1);
597
674
  }
598
675
 
599
- const cloudflaredBin = provider === "cloudflare" ? ensureCloudflared() : null;
676
+ let cloudflaredBin: string | null = null;
677
+ if (provider === "cloudflare") {
678
+ try {
679
+ cloudflaredBin = ensureCloudflared();
680
+ } catch (error) {
681
+ emit.error({
682
+ code: "tunnel_cloudflared_missing",
683
+ message: error instanceof Error ? error.message : String(error),
684
+ });
685
+ process.exit(1);
686
+ }
687
+ }
600
688
  if (provider === "tailscale" && !tailscaleAvailable()) {
601
689
  emit.error({
602
690
  code: "tunnel_tailscale_missing",
@@ -609,20 +697,31 @@ async function up(opts: UpOpts): Promise<void> {
609
697
  const gateLogPath = logPaths.gate;
610
698
  const providerLogPath = logPaths.provider;
611
699
 
612
- const gateProc = spawnGate({
613
- name,
614
- gatePort,
615
- target,
616
- vhost,
617
- provider,
618
- allowedIps: cfg.allowed_ips,
619
- allowPaths,
620
- gateLogPath,
621
- });
700
+ const gateProc = spawnGate(
701
+ {
702
+ name,
703
+ gatePort,
704
+ target,
705
+ vhost,
706
+ provider,
707
+ allowedIps: cfg.allowed_ips,
708
+ allowPaths,
709
+ gateLogPath,
710
+ },
711
+ launch,
712
+ );
622
713
 
623
714
  await sleep(800);
624
715
 
625
- if (!isProcessAlive(gateProc.pid!)) {
716
+ // A host CLI that re-executes itself takes longer to load than a bare script,
717
+ // so wait for the gate to actually listen before the provider forwards to it.
718
+ if (!isProcessAlive(gateProc.pid!) || !(await waitForPortBound(gatePort, 15_000))) {
719
+ try {
720
+ process.kill(gateProc.pid!);
721
+ } catch {
722
+ /* already dead */
723
+ }
724
+ sweepStrays(gatePort, new Set());
626
725
  emit.error({
627
726
  code: "tunnel_gate_failed",
628
727
  message: `Gate failed to start. Check log: ${gateLogPath}`,
@@ -644,6 +743,7 @@ async function up(opts: UpOpts): Promise<void> {
644
743
  path: providerLogPath,
645
744
  command: cloudflaredBin!,
646
745
  arguments: ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`],
746
+ worker: currentWorkerLaunch(),
647
747
  });
648
748
  cfdProc.unref();
649
749
  cloudflaredPid = cfdProc.pid!;
@@ -703,8 +803,9 @@ async function up(opts: UpOpts): Promise<void> {
703
803
  writeState(state);
704
804
  recordTunnelServer(state);
705
805
 
806
+ const bin = resolveBinName();
706
807
  const stopHint =
707
- name === DEFAULT_INSTANCE ? "harn tunnel down" : `harn tunnel down --name ${name}`;
808
+ name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
708
809
  emit.text(`\n Instance: ${name}\n`);
709
810
  emit.text(` Provider: ${provider}${tailscaleMode ? ` (${tailscaleMode})` : ""}\n`);
710
811
  emit.text(` URL: ${url}\n\n`);
@@ -723,11 +824,11 @@ async function up(opts: UpOpts): Promise<void> {
723
824
  }
724
825
  if (!registered) {
725
826
  emit.text(
726
- ` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} && harn tunnel up\n\n`,
827
+ ` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} && ${bin} tunnel up\n\n`,
727
828
  );
728
829
  }
729
830
  emit.text(` Stop: ${stopHint}\n`);
730
- emit.text(" Status: harn tunnel status\n");
831
+ emit.text(` Status: ${bin} tunnel status\n`);
731
832
  }
732
833
 
733
834
  /** The server registry lists tunnels beside every other local server. */
@@ -741,7 +842,13 @@ function recordTunnelServer(state: TunnelState): void {
741
842
  }
742
843
 
743
844
  /** Tear down a single instance by name. Returns the number of processes killed. */
744
- function downOne(name: string, killed: Set<number>): number {
845
+ function portHeldByOtherLiveInstance(port: number, name: string): boolean {
846
+ return listStates().some(
847
+ (s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid),
848
+ );
849
+ }
850
+
851
+ export function downOne(name: string, killed: Set<number>): number {
745
852
  const before = killed.size;
746
853
  const state = readState(name);
747
854
  if (state) {
@@ -759,8 +866,11 @@ function downOne(name: string, killed: Set<number>): number {
759
866
  }
760
867
  }
761
868
  // Fallback: sweep orphans on this instance's gate port, even when state was
762
- // lost; they'd otherwise squat on the port and break the next `up`.
763
- sweepStrays(state?.gate_port ?? DEFAULT_GATE_PORT, killed);
869
+ // lost; they'd otherwise squat on the port and break the next `up`. Skip it
870
+ // when another live instance now owns that port: a stale record's port is
871
+ // reused after a reboot, and sweeping it would kill the live tunnel there.
872
+ const port = state?.gate_port ?? DEFAULT_GATE_PORT;
873
+ if (!portHeldByOtherLiveInstance(port, name)) sweepStrays(port, killed);
764
874
  clearState(name);
765
875
  unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
766
876
  return killed.size - before;
@@ -789,7 +899,7 @@ function down(opts: DownOpts): void {
789
899
  const others = listStates();
790
900
  if (others.length > 0) {
791
901
  emit.text(
792
- `No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`harn tunnel down --name <name>\` or \`harn tunnel down --all\`.\n`,
902
+ `No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`${resolveBinName()} tunnel down --name <name>\` or \`${resolveBinName()} tunnel down --all\`.\n`,
793
903
  );
794
904
  return;
795
905
  }
@@ -807,20 +917,20 @@ function down(opts: DownOpts): void {
807
917
  async function waitForPortFree(port: number, timeoutMs: number): Promise<boolean> {
808
918
  const deadline = Date.now() + timeoutMs;
809
919
  while (Date.now() < deadline) {
810
- if (!listeningPorts().has(port)) return true;
920
+ if (!(await portListening(port))) return true;
811
921
  await sleep(100);
812
922
  }
813
- return !listeningPorts().has(port);
923
+ return !(await portListening(port));
814
924
  }
815
925
 
816
926
  /** Poll until `port` has a LISTEN socket (or the deadline passes). */
817
927
  async function waitForPortBound(port: number, timeoutMs: number): Promise<boolean> {
818
928
  const deadline = Date.now() + timeoutMs;
819
929
  while (Date.now() < deadline) {
820
- if (listeningPorts().has(port)) return true;
930
+ if (await portListening(port)) return true;
821
931
  await sleep(100);
822
932
  }
823
- return listeningPorts().has(port);
933
+ return portListening(port);
824
934
  }
825
935
 
826
936
  /**
@@ -847,8 +957,12 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
847
957
  `Reloading the gate can't bring it back — run \`${bin} tunnel up --name ${name}\`.`,
848
958
  };
849
959
  }
850
- if (!bunAvailable()) {
851
- return { ok: false, message: `[${name}] bun is not on PATH, so the gate can't be respawned.` };
960
+ const launch = currentGateLaunch();
961
+ if (!launch) {
962
+ return {
963
+ ok: false,
964
+ message: `[${name}] Bun is not available, so the gate can't be respawned.`,
965
+ };
852
966
  }
853
967
  if (state.allow_paths.length === 0) {
854
968
  return {
@@ -872,7 +986,7 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
872
986
  /* race: already gone */
873
987
  }
874
988
  }
875
- killByPattern(`gate\\.ts.*--port ${gatePort}( |$)`, killed);
989
+ killByPattern(gatePattern(gatePort), killed);
876
990
 
877
991
  if (!(await waitForPortFree(gatePort, 5_000))) {
878
992
  return {
@@ -884,23 +998,26 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
884
998
  }
885
999
 
886
1000
  const cfg = readConfig();
887
- const gateProc = spawnGate({
888
- name,
889
- gatePort,
890
- target: state.target,
891
- vhost: state.vhost,
892
- provider: state.provider,
893
- allowedIps: cfg.allowed_ips,
894
- allowPaths: state.allow_paths,
895
- gateLogPath: tunnelLogDestinations(name, state.provider).gate,
896
- });
1001
+ const gateProc = spawnGate(
1002
+ {
1003
+ name,
1004
+ gatePort,
1005
+ target: state.target,
1006
+ vhost: state.vhost,
1007
+ provider: state.provider,
1008
+ allowedIps: cfg.allowed_ips,
1009
+ allowPaths: state.allow_paths,
1010
+ gateLogPath: tunnelLogDestinations(name, state.provider).gate,
1011
+ },
1012
+ launch,
1013
+ );
897
1014
 
898
1015
  await sleep(800);
899
1016
  const pid = gateProc.pid;
900
1017
  if (
901
1018
  typeof pid !== "number" ||
902
1019
  !isProcessAlive(pid) ||
903
- !(await waitForPortBound(gatePort, 5_000))
1020
+ !(await waitForPortBound(gatePort, 15_000))
904
1021
  ) {
905
1022
  return {
906
1023
  ok: false,
@@ -1077,9 +1194,16 @@ function allowList(): void {
1077
1194
  emit.text("(empty)\n");
1078
1195
  return;
1079
1196
  }
1080
- for (const ip of cfg.allowed_ips) emit.text(`${ip}\n`);
1197
+ const automatic = cfg.auto_allowed ?? [];
1198
+ for (const ip of cfg.allowed_ips) {
1199
+ emit.text(`${ip}${sameEntryIn(automatic, ip) ? " (automatic)" : ""}\n`);
1200
+ }
1081
1201
  }
1082
1202
 
1203
+ const canonicalEntry = (entry: string) => normalizeAllowEntry(entry) ?? entry;
1204
+ const sameEntryIn = (list: readonly string[], entry: string) =>
1205
+ list.some((e) => canonicalEntry(e) === canonicalEntry(entry));
1206
+
1083
1207
  /**
1084
1208
  * Trailer for `allow add`/`allow rm`. Each gate snapshots the allowlist at
1085
1209
  * spawn, so a config edit alone changes nothing until the gates restart.
@@ -1097,31 +1221,156 @@ function allowChangedHint(): void {
1097
1221
  );
1098
1222
  }
1099
1223
 
1100
- function allowAdd(ip: string): void {
1224
+ interface AllowAddOpts {
1225
+ current?: boolean;
1226
+ }
1227
+
1228
+ async function allowAdd(entry: string | undefined, opts: AllowAddOpts = {}): Promise<void> {
1229
+ if (opts.current) {
1230
+ if (entry) {
1231
+ emit.error({
1232
+ code: "tunnel_allow_current_with_entry",
1233
+ message: "Pass either an address or --current, not both.",
1234
+ });
1235
+ process.exit(1);
1236
+ }
1237
+ const result = await refreshCurrentAddress({ emit, reload: true });
1238
+ if (!result.ok) process.exit(1);
1239
+ return;
1240
+ }
1241
+ if (!entry) {
1242
+ emit.error({
1243
+ code: "tunnel_allow_entry_missing",
1244
+ message: `Name an address or range to allow, or use --current: ${resolveBinName()} tunnel allow add <ip|cidr>`,
1245
+ });
1246
+ process.exit(1);
1247
+ }
1248
+ const canonical = normalizeAllowEntry(entry);
1249
+ if (!canonical) {
1250
+ emit.error({
1251
+ code: "tunnel_allow_entry_invalid",
1252
+ message:
1253
+ `"${entry}" is not an IP address or CIDR range. ` +
1254
+ "Examples: 203.0.113.8, 203.0.113.0/24, 2601:db8:1:2::/64.",
1255
+ });
1256
+ process.exit(1);
1257
+ }
1101
1258
  const cfg = readConfig();
1102
- if (cfg.allowed_ips.includes(ip)) {
1103
- emit.text(`${ip} already in allowlist.\n`);
1259
+ if (sameEntryIn(cfg.allowed_ips, canonical)) {
1260
+ // Adding by hand an entry the refresh owns adopts it: later refreshes keep it.
1261
+ if (cfg.auto_allowed && sameEntryIn(cfg.auto_allowed, canonical)) {
1262
+ cfg.auto_allowed = cfg.auto_allowed.filter((e) => canonicalEntry(e) !== canonical);
1263
+ writeConfig(cfg);
1264
+ emit.text(`${canonical} is now a manual entry; automatic refreshes will keep it.\n`);
1265
+ return;
1266
+ }
1267
+ emit.text(`${canonical} already in allowlist.\n`);
1104
1268
  return;
1105
1269
  }
1106
- cfg.allowed_ips.push(ip);
1270
+ cfg.allowed_ips.push(canonical);
1107
1271
  writeConfig(cfg);
1108
- emit.text(`Added ${ip}.\n`);
1272
+ emit.text(`Added ${canonical}.\n`);
1109
1273
  allowChangedHint();
1110
1274
  }
1111
1275
 
1112
- function allowRm(ip: string): void {
1276
+ function allowRm(entry: string): void {
1113
1277
  const cfg = readConfig();
1114
- const idx = cfg.allowed_ips.indexOf(ip);
1278
+ const wanted = canonicalEntry(entry);
1279
+ const idx = cfg.allowed_ips.findIndex((e) => e === entry || canonicalEntry(e) === wanted);
1115
1280
  if (idx === -1) {
1116
- emit.text(`${ip} not in allowlist.\n`);
1281
+ emit.text(`${entry} not in allowlist.\n`);
1117
1282
  return;
1118
1283
  }
1119
- cfg.allowed_ips.splice(idx, 1);
1284
+ const [removed] = cfg.allowed_ips.splice(idx, 1);
1285
+ if (cfg.auto_allowed) {
1286
+ cfg.auto_allowed = cfg.auto_allowed.filter(
1287
+ (e) => canonicalEntry(e) !== canonicalEntry(removed as string),
1288
+ );
1289
+ }
1120
1290
  writeConfig(cfg);
1121
- emit.text(`Removed ${ip}.\n`);
1291
+ emit.text(`Removed ${removed}.\n`);
1122
1292
  allowChangedHint();
1123
1293
  }
1124
1294
 
1295
+ export interface RefreshCurrentOptions {
1296
+ emit?: EmitContext;
1297
+ /** Reload running Cloudflare gates after an allowlist change, keeping their URLs. */
1298
+ reload: boolean;
1299
+ /** Speak only when the allowlist changed or no address could be detected. */
1300
+ quiet?: boolean;
1301
+ /** Lookup timeout and retries; a sync that must not stall offline passes small ones. */
1302
+ detectOptions?: DetectOptions;
1303
+ /** Replaces the lookup (tests). */
1304
+ detect?: typeof detectPublicAddresses;
1305
+ }
1306
+
1307
+ export interface RefreshCurrentResult {
1308
+ /** False only when a requested reload of a live gate failed. A failed lookup is a warning, not a failure. */
1309
+ ok: boolean;
1310
+ /** The allowlist gained or lost an entry. */
1311
+ changed: boolean;
1312
+ /** Entries newly allowed. */
1313
+ added: string[];
1314
+ removed: string[];
1315
+ warnings: string[];
1316
+ }
1317
+
1318
+ /**
1319
+ * `tunnel allow add --current`: allow this machine's own public addresses, as
1320
+ * Cloudflare sees them, and drop the ones allowed by the previous refresh.
1321
+ * Exported so a host CLI can run it before `tunnel up` and from its own sync.
1322
+ */
1323
+ export async function refreshCurrentAddress(
1324
+ options: RefreshCurrentOptions,
1325
+ ): Promise<RefreshCurrentResult> {
1326
+ if (options.emit) emit = options.emit;
1327
+ const detected = await (options.detect ?? detectPublicAddresses)(options.detectOptions);
1328
+ const cfg = readConfig();
1329
+ const plan = planCurrentAddresses(cfg, detected);
1330
+ const noAddress = !detected.v4 && !detected.v6;
1331
+ // Quiet runs (a host's sync) speak only when something changed or nothing
1332
+ // could be detected; "no IPv6 on this network" is not news every time.
1333
+ if (!options.quiet || noAddress || plan.changed) {
1334
+ for (const warning of plan.warnings) emit.text(` ⚠ ${warning}\n`);
1335
+ }
1336
+ const result: RefreshCurrentResult = {
1337
+ ok: true,
1338
+ changed: plan.changed,
1339
+ added: plan.added,
1340
+ removed: plan.removed,
1341
+ warnings: plan.warnings,
1342
+ };
1343
+ if (noAddress) return result;
1344
+
1345
+ writeConfig(plan.config);
1346
+ if (!options.quiet || plan.changed) {
1347
+ const found = [detected.v4, detected.v6].filter(Boolean).join(", ");
1348
+ emit.text(`This machine reaches Cloudflare as ${found}.\n`);
1349
+ emit.text(
1350
+ plan.changed
1351
+ ? `Allowlist updated${plan.added.length ? `: added ${plan.added.join(", ")}` : ""}${plan.removed.length ? `; removed ${plan.removed.join(", ")}` : ""}.\n`
1352
+ : "Allowlist already covers this machine.\n",
1353
+ );
1354
+ }
1355
+ if (!plan.changed || !options.reload) return result;
1356
+
1357
+ const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1358
+ for (const s of live) {
1359
+ const r = await reloadOne(s);
1360
+ emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
1361
+ if (!r.ok) result.ok = false;
1362
+ }
1363
+ return result;
1364
+ }
1365
+
1366
+ /** Hidden task: run the gate worker in this process (the self-launch form of the gate). */
1367
+ async function runGateInProcess(): Promise<void> {
1368
+ await import("../lib/tunnel/gate.ts");
1369
+ // The gate's server keeps the process alive. Never resolve, so a host's
1370
+ // post-command exit or output finalization cannot end the worker.
1371
+ await new Promise<never>(() => {});
1372
+ }
1373
+
1125
1374
  let emit: EmitContext;
1126
1375
  let context: HarneryProgramContext | undefined;
1127
1376
 
@@ -1206,9 +1455,38 @@ export function registerTunnelCommand(
1206
1455
 
1207
1456
  const allow = cmd
1208
1457
  .command("allow")
1209
- .description("Manage the Cloudflare CF-Connecting-IP allowlist")
1458
+ .description(
1459
+ "Manage the Cloudflare CF-Connecting-IP allowlist (addresses and CIDR ranges, IPv4 and IPv6)",
1460
+ )
1210
1461
  .action(allowList);
1211
- allow.command("add <ip>").description("Add an IP to the allowlist").action(allowAdd);
1212
- allow.command("rm <ip>").description("Remove an IP from the allowlist").action(allowRm);
1213
- allow.command("list").description("List allowed IPs (default action)").action(allowList);
1462
+ allow
1463
+ .command("add [entry]")
1464
+ .description(
1465
+ "Add an IP address or CIDR range (203.0.113.0/24, 2601:db8:1:2::/64), or --current for this machine",
1466
+ )
1467
+ .option(
1468
+ "--current",
1469
+ "allow this machine's public addresses as Cloudflare sees them (IPv4 /32 and IPv6 /64), " +
1470
+ "replace the ones the previous --current added, and reload running gates",
1471
+ )
1472
+ .action(allowAdd);
1473
+ allow
1474
+ .command("rm <entry>")
1475
+ .description("Remove an address or range from the allowlist")
1476
+ .action(allowRm);
1477
+ allow.command("list").description("List allowed entries (default action)").action(allowList);
1478
+
1479
+ cmd
1480
+ .command("log-worker <specification>", { hidden: true })
1481
+ .description("Internal: run the output-rotating wrapper for one tunnel process")
1482
+ .action(async (specification: string) => {
1483
+ await runProcessLogWorker(specification);
1484
+ });
1485
+
1486
+ cmd
1487
+ .command("gate", { hidden: true })
1488
+ .description("Internal: run the gate worker in this process")
1489
+ .option("--name <name>", "instance name")
1490
+ .option("--port <port>", "port to listen on")
1491
+ .action(runGateInProcess);
1214
1492
  }