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.
- package/dist/commander.d.ts +16 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +18 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +54 -4
- package/dist/commands/tunnel.d.ts +65 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +290 -101
- package/dist/commands/web-fetch.d.ts +3 -2
- package/dist/commands/web-fetch.d.ts.map +1 -1
- package/dist/commands/web-fetch.js +25 -19
- package/dist/commands/web.d.ts +7 -1
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +9 -3
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +11 -5
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +57 -13
- package/dist/core/agents/state/names.d.ts +43 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +87 -3
- package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
- package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
- package/dist/core/hooks/cli.js +87 -18
- package/dist/core/hooks/fork-lineage.d.ts +40 -0
- package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
- package/dist/core/hooks/fork-lineage.js +62 -0
- package/dist/core/hooks/resolve/transcript.d.ts +16 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +84 -0
- package/dist/core/hooks/shell-rm.d.ts +2 -0
- package/dist/core/hooks/shell-rm.d.ts.map +1 -0
- package/dist/core/hooks/shell-rm.js +105 -0
- package/dist/core/hooks/shell-tokens.d.ts +12 -0
- package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
- package/dist/core/hooks/shell-tokens.js +97 -0
- package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
- package/dist/core/hooks/shell-waiter.js +3 -93
- package/dist/core/semantic/contract.d.ts +5 -5
- package/dist/core/storage/process-log.d.ts +12 -0
- package/dist/core/storage/process-log.d.ts.map +1 -1
- package/dist/core/storage/process-log.js +10 -2
- package/dist/lib/tunnel/allowlist.d.ts +44 -0
- package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
- package/dist/lib/tunnel/allowlist.js +204 -0
- package/dist/lib/tunnel/current-address.d.ts +41 -0
- package/dist/lib/tunnel/current-address.d.ts.map +1 -0
- package/dist/lib/tunnel/current-address.js +123 -0
- package/dist/lib/tunnel/gate.js +13 -6
- package/dist/lib/tunnel/processes.d.ts +39 -0
- package/dist/lib/tunnel/processes.d.ts.map +1 -0
- package/dist/lib/tunnel/processes.js +121 -0
- package/dist/lib/tunnel/state.d.ts +28 -3
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +98 -27
- package/package.json +2 -1
- package/src/commander.ts +32 -0
- package/src/commands/agents.ts +97 -11
- package/src/commands/tunnel.ts +394 -116
- package/src/commands/web-fetch.ts +30 -19
- package/src/commands/web.ts +9 -3
- package/src/core/agents/render/prompt-context.ts +25 -8
- package/src/core/agents/render/session-context.ts +83 -16
- package/src/core/agents/state/names.ts +117 -6
- package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
- package/src/core/hooks/cli.ts +106 -17
- package/src/core/hooks/fork-lineage.ts +82 -0
- package/src/core/hooks/resolve/transcript.ts +88 -0
- package/src/core/hooks/shell-rm.ts +103 -0
- package/src/core/hooks/shell-tokens.ts +96 -0
- package/src/core/hooks/shell-waiter.ts +4 -91
- package/src/core/storage/process-log.ts +17 -2
- package/src/lib/tunnel/allowlist.ts +208 -0
- package/src/lib/tunnel/current-address.ts +158 -0
- package/src/lib/tunnel/gate.ts +16 -8
- package/src/lib/tunnel/processes.ts +149 -0
- package/src/lib/tunnel/state.ts +123 -27
package/dist/commands/tunnel.js
CHANGED
|
@@ -4,8 +4,11 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { resolveCoordRoot } from "../core/agents/coord-client.js";
|
|
5
5
|
import { resolveBinName } from "../core/config.js";
|
|
6
6
|
import { tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, } from "../core/servers/index.js";
|
|
7
|
-
import { processLogDestination, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
|
|
7
|
+
import { processLogDestination, runProcessLogWorker, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
|
|
8
|
+
import { normalizeAllowEntry } from "../lib/tunnel/allowlist.js";
|
|
9
|
+
import { detectPublicAddresses, planCurrentAddresses, } from "../lib/tunnel/current-address.js";
|
|
8
10
|
import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.js";
|
|
11
|
+
import { connectProbe, findPidsByCommandLine, listeningPorts, portListening, } from "../lib/tunnel/processes.js";
|
|
9
12
|
import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, isTunnelStateLive, listStates, providerLogFile, readConfig, readState, writeConfig, writeState, } from "../lib/tunnel/state.js";
|
|
10
13
|
/**
|
|
11
14
|
* `tunnel`: provider-backed tunnel in front of a local upstream.
|
|
@@ -17,9 +20,11 @@ import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcess
|
|
|
17
20
|
* Every provider also passes through a path scope: `up` requires at least one
|
|
18
21
|
* `--allow-path <prefix>`, and the gate refuses any request outside it.
|
|
19
22
|
*
|
|
20
|
-
* State + config persisted under `.cache/tunnel
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
+
* State + config persisted under `.cache/tunnel/` (or `$HARNERY_TUNNEL_DIR`).
|
|
24
|
+
* cloudflared comes from `$HARNERY_CLOUDFLARED` / the config's `cloudflared_path`
|
|
25
|
+
* when set, else PATH, else auto-installs to ~/.local/bin/ on Linux (macOS and
|
|
26
|
+
* Windows hosts install it themselves); Tailscale requires an installed and
|
|
27
|
+
* authenticated `tailscale` CLI.
|
|
23
28
|
*/
|
|
24
29
|
const DEFAULT_TARGET = "127.0.0.1:8001";
|
|
25
30
|
const DEFAULT_VHOST = "localhost";
|
|
@@ -142,35 +147,87 @@ function collectPath(value, previous) {
|
|
|
142
147
|
function gateScriptPath() {
|
|
143
148
|
return resolve(import.meta.dirname, "..", "lib", "tunnel", "gate.ts");
|
|
144
149
|
}
|
|
150
|
+
/** The gate worker's `--name`/`--port` marker, also what the stray sweep matches. */
|
|
151
|
+
const GATE_SUBCOMMAND = ["tunnel", "gate"];
|
|
145
152
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
153
|
+
* How to start the gate worker, which is a `Bun.serve` process (HTTP and
|
|
154
|
+
* WebSocket reverse proxy):
|
|
155
|
+
* 1. From a source or package checkout, `gate.ts` sits beside this module and
|
|
156
|
+
* runs directly.
|
|
157
|
+
* 2. Inside a single-file bundle there is no such file. The CLI then
|
|
158
|
+
* re-executes itself with the hidden `tunnel gate` task, which loads the
|
|
159
|
+
* gate in-process. This uses the running Bun binary, so Bun need not be
|
|
160
|
+
* on PATH.
|
|
161
|
+
* Null means neither works (a Node host with no Bun, or an unknown entry).
|
|
151
162
|
*/
|
|
152
|
-
function
|
|
153
|
-
|
|
163
|
+
export function resolveGateLaunch(i) {
|
|
164
|
+
if (i.gateScriptExists && (i.underBun || i.bunOnPath)) {
|
|
165
|
+
return {
|
|
166
|
+
command: i.underBun ? i.execPath : "bun",
|
|
167
|
+
arguments: ["run", i.gateScript],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (i.underBun && i.entryScript && i.entryScriptExists) {
|
|
171
|
+
return { command: i.execPath, arguments: [i.entryScript, ...GATE_SUBCOMMAND] };
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* How to start the log wrapper that rotates each process's output. Beside a
|
|
177
|
+
* source or package checkout the default (this package's own wrapper file)
|
|
178
|
+
* works; inside a single-file bundle the CLI re-executes itself with the hidden
|
|
179
|
+
* `tunnel log-worker` task instead.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveWorkerLaunch(i) {
|
|
182
|
+
if (i.gateScriptExists)
|
|
183
|
+
return undefined;
|
|
184
|
+
if (i.underBun && i.entryScript && i.entryScriptExists) {
|
|
185
|
+
return { command: i.execPath, arguments: [i.entryScript, "tunnel", "log-worker"] };
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
154
188
|
}
|
|
189
|
+
function currentLaunchInputs() {
|
|
190
|
+
const gateScript = gateScriptPath();
|
|
191
|
+
const entryScript = process.argv[1] ? resolve(process.argv[1]) : undefined;
|
|
192
|
+
const underBun = typeof process.versions.bun === "string";
|
|
193
|
+
const gateScriptExists = existsSync(gateScript);
|
|
194
|
+
return {
|
|
195
|
+
gateScript,
|
|
196
|
+
gateScriptExists,
|
|
197
|
+
underBun,
|
|
198
|
+
execPath: process.execPath,
|
|
199
|
+
entryScript,
|
|
200
|
+
entryScriptExists: entryScript ? existsSync(entryScript) : false,
|
|
201
|
+
bunOnPath: !underBun && gateScriptExists && bunOnPath(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function currentGateLaunch() {
|
|
205
|
+
return resolveGateLaunch(currentLaunchInputs());
|
|
206
|
+
}
|
|
207
|
+
function currentWorkerLaunch() {
|
|
208
|
+
return resolveWorkerLaunch(currentLaunchInputs());
|
|
209
|
+
}
|
|
210
|
+
function bunOnPath() {
|
|
211
|
+
return spawnSync("bun", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0;
|
|
212
|
+
}
|
|
213
|
+
const requiresBunMessage = () => `${resolveBinName()} tunnel requires Bun: the gate worker is a Bun.serve process. ` +
|
|
214
|
+
"Install Bun (https://bun.sh) and re-run. (Every other command runs on Node.)";
|
|
155
215
|
function tailscaleAvailable() {
|
|
156
|
-
return spawnSync("tailscale", ["version"], { stdio: "ignore" }).status === 0;
|
|
216
|
+
return spawnSync("tailscale", ["version"], { stdio: "ignore", windowsHide: true }).status === 0;
|
|
157
217
|
}
|
|
158
218
|
function sleep(ms) {
|
|
159
219
|
return new Promise((r) => setTimeout(r, ms));
|
|
160
220
|
}
|
|
161
221
|
/**
|
|
162
|
-
* Kill every process whose command line matches `pattern` (
|
|
163
|
-
* skipping our own PID and any already-killed. Returns the count
|
|
164
|
-
* as a fallback so orphaned gate/cloudflared processes get cleaned
|
|
165
|
-
* the state file was lost (which otherwise left them squatting on the
|
|
222
|
+
* Kill every process whose command line matches `pattern` (an extended regular
|
|
223
|
+
* expression), skipping our own PID and any already-killed. Returns the count
|
|
224
|
+
* killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
|
|
225
|
+
* even when the state file was lost (which otherwise left them squatting on the
|
|
226
|
+
* port).
|
|
166
227
|
*/
|
|
167
228
|
function killByPattern(pattern, alreadyKilled) {
|
|
168
|
-
const r = spawnSync("pgrep", ["-f", pattern], { encoding: "utf-8" });
|
|
169
|
-
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
170
|
-
return 0;
|
|
171
229
|
let killed = 0;
|
|
172
|
-
for (const
|
|
173
|
-
const pid = Number(line.trim());
|
|
230
|
+
for (const pid of findPidsByCommandLine(pattern)) {
|
|
174
231
|
if (!pid || pid === process.pid || alreadyKilled.has(pid))
|
|
175
232
|
continue;
|
|
176
233
|
try {
|
|
@@ -184,58 +241,41 @@ function killByPattern(pattern, alreadyKilled) {
|
|
|
184
241
|
}
|
|
185
242
|
return killed;
|
|
186
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Command-line pattern for ONE instance's gate, identified by its port. Matches
|
|
246
|
+
* both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
|
|
247
|
+
* re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
|
|
248
|
+
* guarded with `( |$)` so port 9001 doesn't match 90011.
|
|
249
|
+
*/
|
|
250
|
+
function gatePattern(gatePort) {
|
|
251
|
+
return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
|
|
252
|
+
}
|
|
187
253
|
/**
|
|
188
254
|
* Sweep stray gate + cloudflared processes for ONE instance, identified by its
|
|
189
255
|
* gate port. Both signatures are port-scoped so tearing down one tunnel never
|
|
190
256
|
* touches another:
|
|
191
|
-
* - gate:
|
|
257
|
+
* - gate: see gatePattern (the port is on the gate's argv)
|
|
192
258
|
* - cloudflared: `--url http://localhost:<port>` (order-independent, so it
|
|
193
259
|
* matches regardless of the `--protocol http2` flag we also pass).
|
|
194
|
-
* Port boundary is guarded with `( |$)` so port 9001 doesn't match 90011.
|
|
195
260
|
*/
|
|
196
261
|
function sweepStrays(gatePort, alreadyKilled) {
|
|
197
|
-
return (killByPattern(
|
|
262
|
+
return (killByPattern(gatePattern(gatePort), alreadyKilled) +
|
|
198
263
|
killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled));
|
|
199
264
|
}
|
|
200
|
-
/**
|
|
201
|
-
* Ports currently bound by a LISTEN socket (best-effort, platform-dependent).
|
|
202
|
-
*
|
|
203
|
-
* `ss` (iproute2) is Linux-only. Without the lsof fallback this returns an empty
|
|
204
|
-
* set on macOS/BSD. That does not fail loudly. It reports every port as free, so
|
|
205
|
-
* `allocateGatePort` can hand out an occupied port and the reload port-release
|
|
206
|
-
* check becomes inert.
|
|
207
|
-
*/
|
|
208
|
-
function listeningPorts() {
|
|
209
|
-
const ports = new Set();
|
|
210
|
-
const ss = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
|
|
211
|
-
if (ss.status === 0 && typeof ss.stdout === "string") {
|
|
212
|
-
for (const m of ss.stdout.matchAll(/:(\d+)\s/g))
|
|
213
|
-
ports.add(Number(m[1]));
|
|
214
|
-
return ports;
|
|
215
|
-
}
|
|
216
|
-
// Rows look like: `bun 123 user 4u IPv4 0x… 0t0 TCP 127.0.0.1:58055 (LISTEN)`.
|
|
217
|
-
// Status can be nonzero while stdout still holds usable rows (lsof reports a
|
|
218
|
-
// failure when any single process is unreadable), so judge it on the output.
|
|
219
|
-
const lsof = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf-8" });
|
|
220
|
-
if (typeof lsof.stdout === "string") {
|
|
221
|
-
for (const m of lsof.stdout.matchAll(/:(\d+) \(LISTEN\)$/gm))
|
|
222
|
-
ports.add(Number(m[1]));
|
|
223
|
-
}
|
|
224
|
-
return ports;
|
|
225
|
-
}
|
|
226
265
|
/**
|
|
227
266
|
* Pick a gate port for a new instance. An explicit `--gate-port` is honored
|
|
228
267
|
* (and rejected if it's already taken); otherwise scan upward from 9001 for the
|
|
229
268
|
* first port that's neither held by a live instance nor currently listening.
|
|
230
269
|
*/
|
|
231
|
-
function allocateGatePort(preferred) {
|
|
270
|
+
async function allocateGatePort(preferred) {
|
|
232
271
|
const used = new Set(listStates()
|
|
233
272
|
.filter((s) => isProcessAlive(s.gate_pid))
|
|
234
273
|
.map((s) => s.gate_port));
|
|
274
|
+
// No discovery tool (null) is not "every port free": probe candidates by connecting.
|
|
235
275
|
const listening = listeningPorts();
|
|
236
|
-
const taken = (p) => used.has(p) || listening.has(p);
|
|
276
|
+
const taken = async (p) => used.has(p) || (listening ? listening.has(p) : await connectProbe(p));
|
|
237
277
|
if (preferred !== undefined) {
|
|
238
|
-
if (taken(preferred)) {
|
|
278
|
+
if (await taken(preferred)) {
|
|
239
279
|
emit.error({
|
|
240
280
|
code: "tunnel_port_taken",
|
|
241
281
|
message: `Gate port ${preferred} is already in use. Omit --gate-port to auto-allocate, or pick a free one.`,
|
|
@@ -245,7 +285,7 @@ function allocateGatePort(preferred) {
|
|
|
245
285
|
return preferred;
|
|
246
286
|
}
|
|
247
287
|
for (let p = DEFAULT_GATE_PORT; p <= MAX_GATE_PORT; p++) {
|
|
248
|
-
if (!taken(p))
|
|
288
|
+
if (!(await taken(p)))
|
|
249
289
|
return p;
|
|
250
290
|
}
|
|
251
291
|
emit.error({
|
|
@@ -393,13 +433,14 @@ function tunnelIsAlive(state) {
|
|
|
393
433
|
* is passed as an env var, which the gate snapshots at module load and never
|
|
394
434
|
* re-reads — that snapshot is precisely why `reload` has to exist.
|
|
395
435
|
*/
|
|
396
|
-
function spawnGate(o) {
|
|
436
|
+
function spawnGate(o, launch) {
|
|
397
437
|
// `--name`/`--port` on argv mirror the env vars; they're what makes the gate
|
|
398
|
-
// process distinguishable per-instance in
|
|
438
|
+
// process distinguishable per-instance in a command-line search (see sweepStrays).
|
|
399
439
|
const gateProc = spawnRotatingProcess({
|
|
400
440
|
path: o.gateLogPath,
|
|
401
|
-
command:
|
|
402
|
-
arguments: [
|
|
441
|
+
command: launch.command,
|
|
442
|
+
arguments: [...launch.arguments, "--name", o.name, "--port", String(o.gatePort)],
|
|
443
|
+
worker: currentWorkerLaunch(),
|
|
403
444
|
env: {
|
|
404
445
|
...process.env,
|
|
405
446
|
HARNERY_TUNNEL_ALLOW: o.allowedIps.join(","),
|
|
@@ -414,12 +455,9 @@ function spawnGate(o) {
|
|
|
414
455
|
return gateProc;
|
|
415
456
|
}
|
|
416
457
|
async function up(opts) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
message: "harn tunnel requires Bun: the gate worker is a Bun.serve process. " +
|
|
421
|
-
"Install Bun (https://bun.sh) and re-run. (Every other harn command runs on Node.)",
|
|
422
|
-
});
|
|
458
|
+
const launch = currentGateLaunch();
|
|
459
|
+
if (!launch) {
|
|
460
|
+
emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
|
|
423
461
|
process.exit(1);
|
|
424
462
|
}
|
|
425
463
|
const name = resolveName(opts.name);
|
|
@@ -459,7 +497,7 @@ async function up(opts) {
|
|
|
459
497
|
downOne(name, new Set());
|
|
460
498
|
// Allocate the gate port (after clearing dead state so its old port frees up
|
|
461
499
|
// for reuse). Explicit --gate-port is validated; otherwise auto-scan.
|
|
462
|
-
const gatePort = allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
|
|
500
|
+
const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
|
|
463
501
|
// Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
|
|
464
502
|
// a prior crashed or state-cleared run so the gate port is free before we bind.
|
|
465
503
|
if (sweepStrays(gatePort, new Set()))
|
|
@@ -468,11 +506,23 @@ async function up(opts) {
|
|
|
468
506
|
if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
|
|
469
507
|
emit.error({
|
|
470
508
|
code: "tunnel_allowlist_empty",
|
|
471
|
-
message:
|
|
509
|
+
message: `Allowlist is empty; refusing to start. Add an IP first: ${resolveBinName()} tunnel allow add <ip> (or --current)`,
|
|
472
510
|
});
|
|
473
511
|
process.exit(1);
|
|
474
512
|
}
|
|
475
|
-
|
|
513
|
+
let cloudflaredBin = null;
|
|
514
|
+
if (provider === "cloudflare") {
|
|
515
|
+
try {
|
|
516
|
+
cloudflaredBin = ensureCloudflared();
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
emit.error({
|
|
520
|
+
code: "tunnel_cloudflared_missing",
|
|
521
|
+
message: error instanceof Error ? error.message : String(error),
|
|
522
|
+
});
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
476
526
|
if (provider === "tailscale" && !tailscaleAvailable()) {
|
|
477
527
|
emit.error({
|
|
478
528
|
code: "tunnel_tailscale_missing",
|
|
@@ -492,9 +542,18 @@ async function up(opts) {
|
|
|
492
542
|
allowedIps: cfg.allowed_ips,
|
|
493
543
|
allowPaths,
|
|
494
544
|
gateLogPath,
|
|
495
|
-
});
|
|
545
|
+
}, launch);
|
|
496
546
|
await sleep(800);
|
|
497
|
-
|
|
547
|
+
// A host CLI that re-executes itself takes longer to load than a bare script,
|
|
548
|
+
// so wait for the gate to actually listen before the provider forwards to it.
|
|
549
|
+
if (!isProcessAlive(gateProc.pid) || !(await waitForPortBound(gatePort, 15_000))) {
|
|
550
|
+
try {
|
|
551
|
+
process.kill(gateProc.pid);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
/* already dead */
|
|
555
|
+
}
|
|
556
|
+
sweepStrays(gatePort, new Set());
|
|
498
557
|
emit.error({
|
|
499
558
|
code: "tunnel_gate_failed",
|
|
500
559
|
message: `Gate failed to start. Check log: ${gateLogPath}`,
|
|
@@ -514,6 +573,7 @@ async function up(opts) {
|
|
|
514
573
|
path: providerLogPath,
|
|
515
574
|
command: cloudflaredBin,
|
|
516
575
|
arguments: ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`],
|
|
576
|
+
worker: currentWorkerLaunch(),
|
|
517
577
|
});
|
|
518
578
|
cfdProc.unref();
|
|
519
579
|
cloudflaredPid = cfdProc.pid;
|
|
@@ -567,7 +627,8 @@ async function up(opts) {
|
|
|
567
627
|
};
|
|
568
628
|
writeState(state);
|
|
569
629
|
recordTunnelServer(state);
|
|
570
|
-
const
|
|
630
|
+
const bin = resolveBinName();
|
|
631
|
+
const stopHint = name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
|
|
571
632
|
emit.text(`\n Instance: ${name}\n`);
|
|
572
633
|
emit.text(` Provider: ${provider}${tailscaleMode ? ` (${tailscaleMode})` : ""}\n`);
|
|
573
634
|
emit.text(` URL: ${url}\n\n`);
|
|
@@ -584,10 +645,10 @@ async function up(opts) {
|
|
|
584
645
|
: " Visibility: tailnet only via Tailscale Serve\n\n");
|
|
585
646
|
}
|
|
586
647
|
if (!registered) {
|
|
587
|
-
emit.text(` ⚠ 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} &&
|
|
648
|
+
emit.text(` ⚠ 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`);
|
|
588
649
|
}
|
|
589
650
|
emit.text(` Stop: ${stopHint}\n`);
|
|
590
|
-
emit.text(
|
|
651
|
+
emit.text(` Status: ${bin} tunnel status\n`);
|
|
591
652
|
}
|
|
592
653
|
/** The server registry lists tunnels beside every other local server. */
|
|
593
654
|
function registryRoot() {
|
|
@@ -598,7 +659,10 @@ function recordTunnelServer(state) {
|
|
|
598
659
|
tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
|
|
599
660
|
}
|
|
600
661
|
/** Tear down a single instance by name. Returns the number of processes killed. */
|
|
601
|
-
function
|
|
662
|
+
function portHeldByOtherLiveInstance(port, name) {
|
|
663
|
+
return listStates().some((s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid));
|
|
664
|
+
}
|
|
665
|
+
export function downOne(name, killed) {
|
|
602
666
|
const before = killed.size;
|
|
603
667
|
const state = readState(name);
|
|
604
668
|
if (state) {
|
|
@@ -619,8 +683,12 @@ function downOne(name, killed) {
|
|
|
619
683
|
}
|
|
620
684
|
}
|
|
621
685
|
// Fallback: sweep orphans on this instance's gate port, even when state was
|
|
622
|
-
// lost; they'd otherwise squat on the port and break the next `up`.
|
|
623
|
-
|
|
686
|
+
// lost; they'd otherwise squat on the port and break the next `up`. Skip it
|
|
687
|
+
// when another live instance now owns that port: a stale record's port is
|
|
688
|
+
// reused after a reboot, and sweeping it would kill the live tunnel there.
|
|
689
|
+
const port = state?.gate_port ?? DEFAULT_GATE_PORT;
|
|
690
|
+
if (!portHeldByOtherLiveInstance(port, name))
|
|
691
|
+
sweepStrays(port, killed);
|
|
624
692
|
clearState(name);
|
|
625
693
|
unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
|
|
626
694
|
return killed.size - before;
|
|
@@ -644,7 +712,7 @@ function down(opts) {
|
|
|
644
712
|
if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
|
|
645
713
|
const others = listStates();
|
|
646
714
|
if (others.length > 0) {
|
|
647
|
-
emit.text(`No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse
|
|
715
|
+
emit.text(`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`);
|
|
648
716
|
return;
|
|
649
717
|
}
|
|
650
718
|
}
|
|
@@ -657,21 +725,21 @@ function down(opts) {
|
|
|
657
725
|
async function waitForPortFree(port, timeoutMs) {
|
|
658
726
|
const deadline = Date.now() + timeoutMs;
|
|
659
727
|
while (Date.now() < deadline) {
|
|
660
|
-
if (!
|
|
728
|
+
if (!(await portListening(port)))
|
|
661
729
|
return true;
|
|
662
730
|
await sleep(100);
|
|
663
731
|
}
|
|
664
|
-
return !
|
|
732
|
+
return !(await portListening(port));
|
|
665
733
|
}
|
|
666
734
|
/** Poll until `port` has a LISTEN socket (or the deadline passes). */
|
|
667
735
|
async function waitForPortBound(port, timeoutMs) {
|
|
668
736
|
const deadline = Date.now() + timeoutMs;
|
|
669
737
|
while (Date.now() < deadline) {
|
|
670
|
-
if (
|
|
738
|
+
if (await portListening(port))
|
|
671
739
|
return true;
|
|
672
740
|
await sleep(100);
|
|
673
741
|
}
|
|
674
|
-
return
|
|
742
|
+
return portListening(port);
|
|
675
743
|
}
|
|
676
744
|
/**
|
|
677
745
|
* Restart ONE instance's gate in place, deliberately leaving the provider
|
|
@@ -695,8 +763,12 @@ export async function reloadOne(state) {
|
|
|
695
763
|
`Reloading the gate can't bring it back — run \`${bin} tunnel up --name ${name}\`.`,
|
|
696
764
|
};
|
|
697
765
|
}
|
|
698
|
-
|
|
699
|
-
|
|
766
|
+
const launch = currentGateLaunch();
|
|
767
|
+
if (!launch) {
|
|
768
|
+
return {
|
|
769
|
+
ok: false,
|
|
770
|
+
message: `[${name}] Bun is not available, so the gate can't be respawned.`,
|
|
771
|
+
};
|
|
700
772
|
}
|
|
701
773
|
if (state.allow_paths.length === 0) {
|
|
702
774
|
return {
|
|
@@ -719,7 +791,7 @@ export async function reloadOne(state) {
|
|
|
719
791
|
/* race: already gone */
|
|
720
792
|
}
|
|
721
793
|
}
|
|
722
|
-
killByPattern(
|
|
794
|
+
killByPattern(gatePattern(gatePort), killed);
|
|
723
795
|
if (!(await waitForPortFree(gatePort, 5_000))) {
|
|
724
796
|
return {
|
|
725
797
|
ok: false,
|
|
@@ -737,12 +809,12 @@ export async function reloadOne(state) {
|
|
|
737
809
|
allowedIps: cfg.allowed_ips,
|
|
738
810
|
allowPaths: state.allow_paths,
|
|
739
811
|
gateLogPath: tunnelLogDestinations(name, state.provider).gate,
|
|
740
|
-
});
|
|
812
|
+
}, launch);
|
|
741
813
|
await sleep(800);
|
|
742
814
|
const pid = gateProc.pid;
|
|
743
815
|
if (typeof pid !== "number" ||
|
|
744
816
|
!isProcessAlive(pid) ||
|
|
745
|
-
!(await waitForPortBound(gatePort,
|
|
817
|
+
!(await waitForPortBound(gatePort, 15_000))) {
|
|
746
818
|
return {
|
|
747
819
|
ok: false,
|
|
748
820
|
message: `[${name}] the gate did not come back on port ${gatePort}. ` +
|
|
@@ -902,9 +974,13 @@ function allowList() {
|
|
|
902
974
|
emit.text("(empty)\n");
|
|
903
975
|
return;
|
|
904
976
|
}
|
|
905
|
-
|
|
906
|
-
|
|
977
|
+
const automatic = cfg.auto_allowed ?? [];
|
|
978
|
+
for (const ip of cfg.allowed_ips) {
|
|
979
|
+
emit.text(`${ip}${sameEntryIn(automatic, ip) ? " (automatic)" : ""}\n`);
|
|
980
|
+
}
|
|
907
981
|
}
|
|
982
|
+
const canonicalEntry = (entry) => normalizeAllowEntry(entry) ?? entry;
|
|
983
|
+
const sameEntryIn = (list, entry) => list.some((e) => canonicalEntry(e) === canonicalEntry(entry));
|
|
908
984
|
/**
|
|
909
985
|
* Trailer for `allow add`/`allow rm`. Each gate snapshots the allowlist at
|
|
910
986
|
* spawn, so a config edit alone changes nothing until the gates restart.
|
|
@@ -920,29 +996,122 @@ function allowChangedHint() {
|
|
|
920
996
|
`(${live.map((s) => s.name).join(", ")}):\n` +
|
|
921
997
|
` ${resolveBinName()} tunnel reload --all\n`);
|
|
922
998
|
}
|
|
923
|
-
function allowAdd(
|
|
999
|
+
async function allowAdd(entry, opts = {}) {
|
|
1000
|
+
if (opts.current) {
|
|
1001
|
+
if (entry) {
|
|
1002
|
+
emit.error({
|
|
1003
|
+
code: "tunnel_allow_current_with_entry",
|
|
1004
|
+
message: "Pass either an address or --current, not both.",
|
|
1005
|
+
});
|
|
1006
|
+
process.exit(1);
|
|
1007
|
+
}
|
|
1008
|
+
const result = await refreshCurrentAddress({ emit, reload: true });
|
|
1009
|
+
if (!result.ok)
|
|
1010
|
+
process.exit(1);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (!entry) {
|
|
1014
|
+
emit.error({
|
|
1015
|
+
code: "tunnel_allow_entry_missing",
|
|
1016
|
+
message: `Name an address or range to allow, or use --current: ${resolveBinName()} tunnel allow add <ip|cidr>`,
|
|
1017
|
+
});
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
}
|
|
1020
|
+
const canonical = normalizeAllowEntry(entry);
|
|
1021
|
+
if (!canonical) {
|
|
1022
|
+
emit.error({
|
|
1023
|
+
code: "tunnel_allow_entry_invalid",
|
|
1024
|
+
message: `"${entry}" is not an IP address or CIDR range. ` +
|
|
1025
|
+
"Examples: 203.0.113.8, 203.0.113.0/24, 2601:db8:1:2::/64.",
|
|
1026
|
+
});
|
|
1027
|
+
process.exit(1);
|
|
1028
|
+
}
|
|
924
1029
|
const cfg = readConfig();
|
|
925
|
-
if (cfg.allowed_ips
|
|
926
|
-
|
|
1030
|
+
if (sameEntryIn(cfg.allowed_ips, canonical)) {
|
|
1031
|
+
// Adding by hand an entry the refresh owns adopts it: later refreshes keep it.
|
|
1032
|
+
if (cfg.auto_allowed && sameEntryIn(cfg.auto_allowed, canonical)) {
|
|
1033
|
+
cfg.auto_allowed = cfg.auto_allowed.filter((e) => canonicalEntry(e) !== canonical);
|
|
1034
|
+
writeConfig(cfg);
|
|
1035
|
+
emit.text(`${canonical} is now a manual entry; automatic refreshes will keep it.\n`);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
emit.text(`${canonical} already in allowlist.\n`);
|
|
927
1039
|
return;
|
|
928
1040
|
}
|
|
929
|
-
cfg.allowed_ips.push(
|
|
1041
|
+
cfg.allowed_ips.push(canonical);
|
|
930
1042
|
writeConfig(cfg);
|
|
931
|
-
emit.text(`Added ${
|
|
1043
|
+
emit.text(`Added ${canonical}.\n`);
|
|
932
1044
|
allowChangedHint();
|
|
933
1045
|
}
|
|
934
|
-
function allowRm(
|
|
1046
|
+
function allowRm(entry) {
|
|
935
1047
|
const cfg = readConfig();
|
|
936
|
-
const
|
|
1048
|
+
const wanted = canonicalEntry(entry);
|
|
1049
|
+
const idx = cfg.allowed_ips.findIndex((e) => e === entry || canonicalEntry(e) === wanted);
|
|
937
1050
|
if (idx === -1) {
|
|
938
|
-
emit.text(`${
|
|
1051
|
+
emit.text(`${entry} not in allowlist.\n`);
|
|
939
1052
|
return;
|
|
940
1053
|
}
|
|
941
|
-
cfg.allowed_ips.splice(idx, 1);
|
|
1054
|
+
const [removed] = cfg.allowed_ips.splice(idx, 1);
|
|
1055
|
+
if (cfg.auto_allowed) {
|
|
1056
|
+
cfg.auto_allowed = cfg.auto_allowed.filter((e) => canonicalEntry(e) !== canonicalEntry(removed));
|
|
1057
|
+
}
|
|
942
1058
|
writeConfig(cfg);
|
|
943
|
-
emit.text(`Removed ${
|
|
1059
|
+
emit.text(`Removed ${removed}.\n`);
|
|
944
1060
|
allowChangedHint();
|
|
945
1061
|
}
|
|
1062
|
+
/**
|
|
1063
|
+
* `tunnel allow add --current`: allow this machine's own public addresses, as
|
|
1064
|
+
* Cloudflare sees them, and drop the ones allowed by the previous refresh.
|
|
1065
|
+
* Exported so a host CLI can run it before `tunnel up` and from its own sync.
|
|
1066
|
+
*/
|
|
1067
|
+
export async function refreshCurrentAddress(options) {
|
|
1068
|
+
if (options.emit)
|
|
1069
|
+
emit = options.emit;
|
|
1070
|
+
const detected = await (options.detect ?? detectPublicAddresses)(options.detectOptions);
|
|
1071
|
+
const cfg = readConfig();
|
|
1072
|
+
const plan = planCurrentAddresses(cfg, detected);
|
|
1073
|
+
const noAddress = !detected.v4 && !detected.v6;
|
|
1074
|
+
// Quiet runs (a host's sync) speak only when something changed or nothing
|
|
1075
|
+
// could be detected; "no IPv6 on this network" is not news every time.
|
|
1076
|
+
if (!options.quiet || noAddress || plan.changed) {
|
|
1077
|
+
for (const warning of plan.warnings)
|
|
1078
|
+
emit.text(` ⚠ ${warning}\n`);
|
|
1079
|
+
}
|
|
1080
|
+
const result = {
|
|
1081
|
+
ok: true,
|
|
1082
|
+
changed: plan.changed,
|
|
1083
|
+
added: plan.added,
|
|
1084
|
+
removed: plan.removed,
|
|
1085
|
+
warnings: plan.warnings,
|
|
1086
|
+
};
|
|
1087
|
+
if (noAddress)
|
|
1088
|
+
return result;
|
|
1089
|
+
writeConfig(plan.config);
|
|
1090
|
+
if (!options.quiet || plan.changed) {
|
|
1091
|
+
const found = [detected.v4, detected.v6].filter(Boolean).join(", ");
|
|
1092
|
+
emit.text(`This machine reaches Cloudflare as ${found}.\n`);
|
|
1093
|
+
emit.text(plan.changed
|
|
1094
|
+
? `Allowlist updated${plan.added.length ? `: added ${plan.added.join(", ")}` : ""}${plan.removed.length ? `; removed ${plan.removed.join(", ")}` : ""}.\n`
|
|
1095
|
+
: "Allowlist already covers this machine.\n");
|
|
1096
|
+
}
|
|
1097
|
+
if (!plan.changed || !options.reload)
|
|
1098
|
+
return result;
|
|
1099
|
+
const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
|
|
1100
|
+
for (const s of live) {
|
|
1101
|
+
const r = await reloadOne(s);
|
|
1102
|
+
emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
|
|
1103
|
+
if (!r.ok)
|
|
1104
|
+
result.ok = false;
|
|
1105
|
+
}
|
|
1106
|
+
return result;
|
|
1107
|
+
}
|
|
1108
|
+
/** Hidden task: run the gate worker in this process (the self-launch form of the gate). */
|
|
1109
|
+
async function runGateInProcess() {
|
|
1110
|
+
await import("../lib/tunnel/gate.js");
|
|
1111
|
+
// The gate's server keeps the process alive. Never resolve, so a host's
|
|
1112
|
+
// post-command exit or output finalization cannot end the worker.
|
|
1113
|
+
await new Promise(() => { });
|
|
1114
|
+
}
|
|
946
1115
|
let emit;
|
|
947
1116
|
let context;
|
|
948
1117
|
export function registerTunnelCommand(program, emitParam, contextParam) {
|
|
@@ -994,9 +1163,29 @@ export function registerTunnelCommand(program, emitParam, contextParam) {
|
|
|
994
1163
|
.action(logs);
|
|
995
1164
|
const allow = cmd
|
|
996
1165
|
.command("allow")
|
|
997
|
-
.description("Manage the Cloudflare CF-Connecting-IP allowlist")
|
|
1166
|
+
.description("Manage the Cloudflare CF-Connecting-IP allowlist (addresses and CIDR ranges, IPv4 and IPv6)")
|
|
998
1167
|
.action(allowList);
|
|
999
|
-
allow
|
|
1000
|
-
|
|
1001
|
-
|
|
1168
|
+
allow
|
|
1169
|
+
.command("add [entry]")
|
|
1170
|
+
.description("Add an IP address or CIDR range (203.0.113.0/24, 2601:db8:1:2::/64), or --current for this machine")
|
|
1171
|
+
.option("--current", "allow this machine's public addresses as Cloudflare sees them (IPv4 /32 and IPv6 /64), " +
|
|
1172
|
+
"replace the ones the previous --current added, and reload running gates")
|
|
1173
|
+
.action(allowAdd);
|
|
1174
|
+
allow
|
|
1175
|
+
.command("rm <entry>")
|
|
1176
|
+
.description("Remove an address or range from the allowlist")
|
|
1177
|
+
.action(allowRm);
|
|
1178
|
+
allow.command("list").description("List allowed entries (default action)").action(allowList);
|
|
1179
|
+
cmd
|
|
1180
|
+
.command("log-worker <specification>", { hidden: true })
|
|
1181
|
+
.description("Internal: run the output-rotating wrapper for one tunnel process")
|
|
1182
|
+
.action(async (specification) => {
|
|
1183
|
+
await runProcessLogWorker(specification);
|
|
1184
|
+
});
|
|
1185
|
+
cmd
|
|
1186
|
+
.command("gate", { hidden: true })
|
|
1187
|
+
.description("Internal: run the gate worker in this process")
|
|
1188
|
+
.option("--name <name>", "instance name")
|
|
1189
|
+
.option("--port <port>", "port to listen on")
|
|
1190
|
+
.action(runGateInProcess);
|
|
1002
1191
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import type { EmitContext } from "../commander.js";
|
|
2
3
|
/** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
|
|
3
|
-
export declare function webRunner(): "bun" | "npm";
|
|
4
|
+
export declare function webRunner(spawn?: typeof spawnSync): "bun" | "npm";
|
|
4
5
|
/**
|
|
5
6
|
* Ensure a cached harnery checkout's `web/` exists for this version and return
|
|
6
7
|
* its path, fetching (clone + install) on first use. Returns null on failure
|
|
7
8
|
* (after emitting an actionable error).
|
|
8
9
|
*/
|
|
9
|
-
export declare function lazyFetchWebRoot(emit: EmitContext): string | null;
|
|
10
|
+
export declare function lazyFetchWebRoot(emit: EmitContext, spawn?: typeof spawnSync): string | null;
|
|
10
11
|
//# sourceMappingURL=web-fetch.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-fetch.d.ts","sourceRoot":"","sources":["../../src/commands/web-fetch.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"web-fetch.d.ts","sourceRoot":"","sources":["../../src/commands/web-fetch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAK/C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAmDnD,2GAA2G;AAC3G,wBAAgB,SAAS,CAAC,KAAK,GAAE,OAAO,SAAqB,GAAG,KAAK,GAAG,KAAK,CAG5E;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,WAAW,EACjB,KAAK,GAAE,OAAO,SAAqB,GAClC,MAAM,GAAG,IAAI,CAyDf"}
|