harnery 0.45.0 → 0.45.1
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.js +0 -1
- package/dist/commands/files.d.ts +2 -2
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +4 -7
- package/dist/commands/servers.d.ts.map +1 -1
- package/dist/commands/servers.js +17 -11
- package/dist/commands/tunnel.d.ts +0 -15
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +153 -453
- package/dist/core/agents/live-authority-v3.d.ts +0 -1
- package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
- package/dist/core/agents/live-authority-v3.js +0 -11
- package/dist/core/agents/live-lifecycle-v3.d.ts +2 -4
- package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
- package/dist/core/agents/live-lifecycle-v3.js +7 -28
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +14 -25
- package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-writer.js +0 -5
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +0 -5
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +70 -180
- package/dist/core/artifacts/activity.d.ts +4 -1
- package/dist/core/artifacts/activity.d.ts.map +1 -1
- package/dist/core/artifacts/activity.js +8 -2
- package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
- package/dist/core/artifacts/delivery-card.js +1 -2
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +38 -12
- package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
- package/dist/core/events/v3/authority-outbox.js +0 -29
- package/dist/core/events/v3/producers/coordination-recorder.d.ts +0 -1
- package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/coordination-recorder.js +0 -7
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +64 -77
- package/dist/core/hooks/cli.js +78 -48
- package/dist/core/servers/index.d.ts +13 -10
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +97 -192
- package/dist/core/servers/tunnels.d.ts +3 -5
- package/dist/core/servers/tunnels.d.ts.map +1 -1
- package/dist/core/servers/tunnels.js +2 -45
- package/dist/core/storage/durable-history.js +56 -22
- package/dist/core/storage/segments.d.ts.map +1 -1
- package/dist/core/storage/segments.js +73 -17
- package/dist/core/work/state.js +7 -7
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +21 -65
- package/dist/core/workflow/live-session-v3.d.ts +0 -6
- package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
- package/dist/core/workflow/live-session-v3.js +5 -126
- package/dist/core/workflow/proof.d.ts +4 -4
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +4 -12
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +0 -19
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +0 -13
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +0 -19
- package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
- package/dist/core/workflow/spawn-opencode.js +0 -14
- package/dist/core/workflow/types.d.ts +11 -54
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +0 -4
- package/dist/lib/tunnel/state.d.ts +6 -13
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +12 -58
- package/package.json +1 -6
- package/src/commander.ts +0 -10
- package/src/commands/files.ts +5 -13
- package/src/commands/servers.ts +23 -12
- package/src/commands/tunnel.ts +166 -533
- package/src/core/agents/live-authority-v3.ts +0 -17
- package/src/core/agents/live-lifecycle-v3.ts +14 -52
- package/src/core/agents/state/heartbeat-writer.ts +12 -29
- package/src/core/agents/state/live-coordination-writer.ts +0 -29
- package/src/core/agents/state/pidmap.ts +0 -13
- package/src/core/agents/state/stale-sweep.ts +82 -247
- package/src/core/artifacts/activity.ts +9 -2
- package/src/core/artifacts/delivery-card.ts +1 -7
- package/src/core/artifacts/index.ts +45 -12
- package/src/core/events/v3/authority-outbox.ts +0 -56
- package/src/core/events/v3/producers/coordination-recorder.ts +0 -15
- package/src/core/events/v3/producers/recorder.ts +68 -94
- package/src/core/hooks/cli.ts +85 -47
- package/src/core/servers/index.ts +106 -228
- package/src/core/servers/tunnels.ts +3 -55
- package/src/core/storage/durable-history.ts +52 -20
- package/src/core/storage/segments.ts +79 -17
- package/src/core/work/state.ts +7 -7
- package/src/core/workflow/engine.ts +21 -73
- package/src/core/workflow/live-session-v3.ts +5 -146
- package/src/core/workflow/proof.ts +5 -21
- package/src/core/workflow/spawn-claude.ts +0 -18
- package/src/core/workflow/spawn-codex.ts +0 -13
- package/src/core/workflow/spawn-cursor.ts +0 -18
- package/src/core/workflow/spawn-opencode.ts +0 -14
- package/src/core/workflow/types.ts +11 -41
- package/src/lib/identities/assume.ts +0 -4
- package/src/lib/tunnel/state.ts +15 -70
- package/dist/commands/turn-rollback.d.ts +0 -5
- package/dist/commands/turn-rollback.d.ts.map +0 -1
- package/dist/commands/turn-rollback.js +0 -76
- package/dist/core/agents/state/unchanged-file.d.ts +0 -44
- package/dist/core/agents/state/unchanged-file.d.ts.map +0 -1
- package/dist/core/agents/state/unchanged-file.js +0 -390
- package/dist/core/events/v3/generation-custody.d.ts +0 -5
- package/dist/core/events/v3/generation-custody.d.ts.map +0 -1
- package/dist/core/events/v3/generation-custody.js +0 -50
- package/dist/core/turn-rollback/content.d.ts +0 -53
- package/dist/core/turn-rollback/content.d.ts.map +0 -1
- package/dist/core/turn-rollback/content.js +0 -396
- package/dist/core/turn-rollback/index.d.ts +0 -72
- package/dist/core/turn-rollback/index.d.ts.map +0 -1
- package/dist/core/turn-rollback/index.js +0 -443
- package/dist/core/workflow/failure-evidence.d.ts +0 -16
- package/dist/core/workflow/failure-evidence.d.ts.map +0 -1
- package/dist/core/workflow/failure-evidence.js +0 -114
- package/src/commands/turn-rollback.ts +0 -111
- package/src/core/agents/state/unchanged-file.ts +0 -456
- package/src/core/events/v3/generation-custody.ts +0 -59
- package/src/core/turn-rollback/content.ts +0 -499
- package/src/core/turn-rollback/index.ts +0 -580
- package/src/core/workflow/failure-evidence.ts +0 -141
package/dist/commands/tunnel.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
-
import { hostname } from "node:os";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
3
|
import { resolve } from "node:path";
|
|
6
4
|
import { resolveCoordRoot } from "../core/agents/coord-client.js";
|
|
7
5
|
import { resolveBinName } from "../core/config.js";
|
|
8
|
-
import {
|
|
9
|
-
import { writePrivateJsonAtomic } from "../core/storage/atomic-json.js";
|
|
6
|
+
import { tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, } from "../core/servers/index.js";
|
|
10
7
|
import { processLogDestination, runProcessLogWorker, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
|
|
11
8
|
import { normalizeAllowEntry } from "../lib/tunnel/allowlist.js";
|
|
12
9
|
import { detectPublicAddresses, planCurrentAddresses, } from "../lib/tunnel/current-address.js";
|
|
13
10
|
import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.js";
|
|
14
|
-
import { connectProbe, listeningPorts, portListening } from "../lib/tunnel/processes.js";
|
|
15
|
-
import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, providerLogFile, readConfig,
|
|
11
|
+
import { connectProbe, findPidsByCommandLine, listeningPorts, portListening, } from "../lib/tunnel/processes.js";
|
|
12
|
+
import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, isTunnelStateLive, listStates, providerLogFile, readConfig, readState, writeConfig, writeState, } from "../lib/tunnel/state.js";
|
|
16
13
|
/**
|
|
17
14
|
* `tunnel`: provider-backed tunnel in front of a local upstream.
|
|
18
15
|
*
|
|
@@ -221,13 +218,57 @@ function tailscaleAvailable() {
|
|
|
221
218
|
function sleep(ms) {
|
|
222
219
|
return new Promise((r) => setTimeout(r, ms));
|
|
223
220
|
}
|
|
221
|
+
/**
|
|
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).
|
|
227
|
+
*/
|
|
228
|
+
function killByPattern(pattern, alreadyKilled) {
|
|
229
|
+
let killed = 0;
|
|
230
|
+
for (const pid of findPidsByCommandLine(pattern)) {
|
|
231
|
+
if (!pid || pid === process.pid || alreadyKilled.has(pid))
|
|
232
|
+
continue;
|
|
233
|
+
try {
|
|
234
|
+
process.kill(pid);
|
|
235
|
+
alreadyKilled.add(pid);
|
|
236
|
+
killed++;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
/* race: already gone */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return killed;
|
|
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
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Sweep stray gate + cloudflared processes for ONE instance, identified by its
|
|
255
|
+
* gate port. Both signatures are port-scoped so tearing down one tunnel never
|
|
256
|
+
* touches another:
|
|
257
|
+
* - gate: see gatePattern (the port is on the gate's argv)
|
|
258
|
+
* - cloudflared: `--url http://localhost:<port>` (order-independent, so it
|
|
259
|
+
* matches regardless of the `--protocol http2` flag we also pass).
|
|
260
|
+
*/
|
|
261
|
+
function sweepStrays(gatePort, alreadyKilled) {
|
|
262
|
+
return (killByPattern(gatePattern(gatePort), alreadyKilled) +
|
|
263
|
+
killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled));
|
|
264
|
+
}
|
|
224
265
|
/**
|
|
225
266
|
* Pick a gate port for a new instance. An explicit `--gate-port` is honored
|
|
226
267
|
* (and rejected if it's already taken); otherwise scan upward from 9001 for the
|
|
227
268
|
* first port that's neither held by a live instance nor currently listening.
|
|
228
269
|
*/
|
|
229
270
|
async function allocateGatePort(preferred) {
|
|
230
|
-
const used = new Set(
|
|
271
|
+
const used = new Set(listStates()
|
|
231
272
|
.filter((s) => isProcessAlive(s.gate_pid))
|
|
232
273
|
.map((s) => s.gate_port));
|
|
233
274
|
// No discovery tool (null) is not "every port free": probe candidates by connecting.
|
|
@@ -342,16 +383,14 @@ function runTailscaleShare(mode, targetUrl, path, httpsPort, logPath) {
|
|
|
342
383
|
}
|
|
343
384
|
}
|
|
344
385
|
function stopTailscaleShare(state) {
|
|
345
|
-
if (state.provider !== "tailscale")
|
|
386
|
+
if (state.provider !== "tailscale" || !state.tailscale_mode)
|
|
346
387
|
return true;
|
|
347
|
-
if (!originalTailscaleRoute(state))
|
|
348
|
-
return false;
|
|
349
388
|
const cmd = tailscaleCommand(state.tailscale_mode);
|
|
350
389
|
const logPath = tunnelLogDestinations(state.name, "tailscale").provider;
|
|
351
390
|
const args = [
|
|
352
391
|
cmd,
|
|
353
|
-
`--https=${state.tailscale_https_port}`,
|
|
354
|
-
`--set-path=${state.tailscale_path}`,
|
|
392
|
+
`--https=${state.tailscale_https_port ?? 443}`,
|
|
393
|
+
`--set-path=${state.tailscale_path ?? "/"}`,
|
|
355
394
|
"off",
|
|
356
395
|
];
|
|
357
396
|
const status = runRotatingProcessSync({
|
|
@@ -363,9 +402,9 @@ function stopTailscaleShare(state) {
|
|
|
363
402
|
// The gate is torn down and its port is freed for reuse regardless, so a
|
|
364
403
|
// surviving serve/funnel mapping could later re-expose whatever next binds
|
|
365
404
|
// that port. Surface it loudly so the operator can clear it by hand.
|
|
366
|
-
emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path}).\n` +
|
|
405
|
+
emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path ?? "/"}).\n` +
|
|
367
406
|
` It may still be exposing this machine. Clear it with:\n` +
|
|
368
|
-
` tailscale ${cmd} --https=${state.tailscale_https_port} --set-path=${state.tailscale_path} off\n` +
|
|
407
|
+
` tailscale ${cmd} --https=${state.tailscale_https_port ?? 443} --set-path=${state.tailscale_path ?? "/"} off\n` +
|
|
369
408
|
` Log: ${logPath}\n\n`);
|
|
370
409
|
return false;
|
|
371
410
|
}
|
|
@@ -378,39 +417,13 @@ function contextVhost() {
|
|
|
378
417
|
return resolved ?? null;
|
|
379
418
|
}
|
|
380
419
|
function providerIsAlive(state) {
|
|
381
|
-
if (!canonicalProviderFields(state))
|
|
382
|
-
return false;
|
|
383
420
|
if (state.provider === "tailscale")
|
|
384
|
-
return
|
|
385
|
-
const pid = state.cloudflared_pid;
|
|
421
|
+
return true;
|
|
422
|
+
const pid = state.cloudflared_pid ?? state.provider_pid;
|
|
386
423
|
return typeof pid === "number" && isProcessAlive(pid);
|
|
387
424
|
}
|
|
388
|
-
function
|
|
389
|
-
return (
|
|
390
|
-
!Object.hasOwn(state, "provider_start_token") &&
|
|
391
|
-
(state.provider !== "tailscale" ||
|
|
392
|
-
(state.cloudflared_pid === undefined && state.cloudflared_start_token === undefined)));
|
|
393
|
-
}
|
|
394
|
-
function originalProcessWitnesses(state) {
|
|
395
|
-
if (!canonicalProviderFields(state) ||
|
|
396
|
-
!Number.isSafeInteger(state.gate_pid) ||
|
|
397
|
-
state.gate_pid < 1 ||
|
|
398
|
-
typeof state.gate_start_token !== "string" ||
|
|
399
|
-
!state.gate_start_token ||
|
|
400
|
-
typeof state.registration_id !== "string" ||
|
|
401
|
-
!state.registration_id)
|
|
402
|
-
return false;
|
|
403
|
-
if (state.provider === "tailscale")
|
|
404
|
-
return (originalTailscaleRoute(state) &&
|
|
405
|
-
(state.provider_state === "ready" || state.provider_state === "pending"));
|
|
406
|
-
return (state.provider === "cloudflare" &&
|
|
407
|
-
Number.isSafeInteger(state.cloudflared_pid) &&
|
|
408
|
-
state.cloudflared_pid > 0 &&
|
|
409
|
-
typeof state.cloudflared_start_token === "string" &&
|
|
410
|
-
!!state.cloudflared_start_token);
|
|
411
|
-
}
|
|
412
|
-
function readinessOf(state) {
|
|
413
|
-
return tunnelReadiness(state, createTunnelReadinessVerifier({ coordRoot: registryRoot() }));
|
|
425
|
+
function tunnelIsAlive(state) {
|
|
426
|
+
return isTunnelStateLive(state);
|
|
414
427
|
}
|
|
415
428
|
/**
|
|
416
429
|
* Spawn the detached gate worker.
|
|
@@ -421,7 +434,8 @@ function readinessOf(state) {
|
|
|
421
434
|
* re-reads — that snapshot is precisely why `reload` has to exist.
|
|
422
435
|
*/
|
|
423
436
|
function spawnGate(o, launch) {
|
|
424
|
-
//
|
|
437
|
+
// `--name`/`--port` on argv mirror the env vars; they're what makes the gate
|
|
438
|
+
// process distinguishable per-instance in a command-line search (see sweepStrays).
|
|
425
439
|
const gateProc = spawnRotatingProcess({
|
|
426
440
|
path: o.gateLogPath,
|
|
427
441
|
command: launch.command,
|
|
@@ -440,79 +454,7 @@ function spawnGate(o, launch) {
|
|
|
440
454
|
gateProc.unref();
|
|
441
455
|
return gateProc;
|
|
442
456
|
}
|
|
443
|
-
function originalTailscaleRoute(state) {
|
|
444
|
-
return ((state.tailscale_mode === "serve" || state.tailscale_mode === "funnel") &&
|
|
445
|
-
typeof state.tailscale_path === "string" &&
|
|
446
|
-
state.tailscale_path.startsWith("/") &&
|
|
447
|
-
Number.isSafeInteger(state.tailscale_https_port) &&
|
|
448
|
-
state.tailscale_https_port > 0 &&
|
|
449
|
-
state.tailscale_https_port <= 65535);
|
|
450
|
-
}
|
|
451
|
-
function routeOf(state) {
|
|
452
|
-
return state?.provider === "tailscale" && originalTailscaleRoute(state)
|
|
453
|
-
? { path: state.tailscale_path, httpsPort: state.tailscale_https_port }
|
|
454
|
-
: null;
|
|
455
|
-
}
|
|
456
|
-
function routeKey(route) {
|
|
457
|
-
return `${route.httpsPort}:${route.path}`;
|
|
458
|
-
}
|
|
459
|
-
export function withTunnelLifecycleCustody(name, requested, effect) {
|
|
460
|
-
if (!/^[a-z0-9][a-z0-9-]*$/i.test(name))
|
|
461
|
-
throw new Error("invalid tunnel lifecycle name");
|
|
462
|
-
const leases = [];
|
|
463
|
-
const release = () => {
|
|
464
|
-
for (const lease of leases.splice(0).reverse())
|
|
465
|
-
lease.release();
|
|
466
|
-
};
|
|
467
|
-
const acquire = (category, key) => {
|
|
468
|
-
const scope = `tunnel-${category}-${createHash("sha256").update(key).digest("hex")}`;
|
|
469
|
-
leases.push(acquireNoClobberLease({
|
|
470
|
-
path: resolve(tunnelDir(), ".custody", category, createHash("sha256").update(key).digest("hex")),
|
|
471
|
-
scope,
|
|
472
|
-
authoritySha256: createHash("sha256").update(scope).digest("hex"),
|
|
473
|
-
staleAfterMs: 1,
|
|
474
|
-
}));
|
|
475
|
-
};
|
|
476
|
-
try {
|
|
477
|
-
acquire("instance", name.toLowerCase());
|
|
478
|
-
const retained = listSelectedStates();
|
|
479
|
-
const existing = routeOf(retained.find((state) => state.name === name) ?? null);
|
|
480
|
-
const routes = [
|
|
481
|
-
...new Set([existing, requested].filter((route) => !!route).map(routeKey)),
|
|
482
|
-
].sort();
|
|
483
|
-
for (const key of routes)
|
|
484
|
-
acquire("route", key);
|
|
485
|
-
for (const other of retained) {
|
|
486
|
-
if (other.name.toLowerCase() === name.toLowerCase()) {
|
|
487
|
-
if (other.name !== name)
|
|
488
|
-
throw new Error(`tunnel instance ${name} aliases retained spelling ${other.name}; lifecycle custody is unavailable`);
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
const route = routeOf(other);
|
|
492
|
-
if (route && routes.includes(routeKey(route)))
|
|
493
|
-
throw new Error(`Tailscale route ${routeKey(route)} is retained by tunnel ${other.name}; provider custody is unavailable`);
|
|
494
|
-
}
|
|
495
|
-
const result = effect();
|
|
496
|
-
if (result !== null &&
|
|
497
|
-
(typeof result === "object" || typeof result === "function") &&
|
|
498
|
-
typeof result.then === "function")
|
|
499
|
-
return Promise.resolve(result).finally(release);
|
|
500
|
-
release();
|
|
501
|
-
return result;
|
|
502
|
-
}
|
|
503
|
-
catch (error) {
|
|
504
|
-
release();
|
|
505
|
-
throw error;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
457
|
async function up(opts) {
|
|
509
|
-
const name = resolveName(opts.name);
|
|
510
|
-
const requested = resolveProvider(opts.provider) === "tailscale"
|
|
511
|
-
? { path: resolveTailscalePath(opts.path, name), httpsPort: resolveHttpsPort(opts.httpsPort) }
|
|
512
|
-
: null;
|
|
513
|
-
return withTunnelLifecycleCustody(name, requested, () => upWithCustody(opts));
|
|
514
|
-
}
|
|
515
|
-
async function upWithCustody(opts) {
|
|
516
458
|
const launch = currentGateLaunch();
|
|
517
459
|
if (!launch) {
|
|
518
460
|
emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
|
|
@@ -528,17 +470,8 @@ async function upWithCustody(opts) {
|
|
|
528
470
|
const tailscalePath = provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
|
|
529
471
|
const tailscaleHttpsPort = provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
|
|
530
472
|
const allowPaths = resolveAllowPaths(opts.allowPath);
|
|
531
|
-
const existing =
|
|
532
|
-
|
|
533
|
-
if (readiness === "unknown") {
|
|
534
|
-
emit.error({
|
|
535
|
-
code: "tunnel_custody_unknown",
|
|
536
|
-
message: `Tunnel [${name}] readiness is unknown; retained its state and refused cleanup or replacement.`,
|
|
537
|
-
});
|
|
538
|
-
process.exit(1);
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
if (existing && readiness === "ready") {
|
|
473
|
+
const existing = readState(name);
|
|
474
|
+
if (existing && tunnelIsAlive(existing)) {
|
|
542
475
|
if (existing.provider !== provider) {
|
|
543
476
|
emit.error({
|
|
544
477
|
code: "tunnel_instance_in_use",
|
|
@@ -560,19 +493,15 @@ async function upWithCustody(opts) {
|
|
|
560
493
|
emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
|
|
561
494
|
return;
|
|
562
495
|
}
|
|
563
|
-
if (existing)
|
|
564
|
-
|
|
565
|
-
if (readSelectedState(name)) {
|
|
566
|
-
emit.error({
|
|
567
|
-
code: "tunnel_custody_unknown",
|
|
568
|
-
message: `Tunnel [${name}] has a live or unverifiable process; retained its state and refused a replacement.`,
|
|
569
|
-
});
|
|
570
|
-
process.exit(1);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
496
|
+
if (existing)
|
|
497
|
+
downOne(name, new Set());
|
|
573
498
|
// Allocate the gate port (after clearing dead state so its old port frees up
|
|
574
499
|
// for reuse). Explicit --gate-port is validated; otherwise auto-scan.
|
|
575
500
|
const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
|
|
501
|
+
// Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
|
|
502
|
+
// a prior crashed or state-cleared run so the gate port is free before we bind.
|
|
503
|
+
if (sweepStrays(gatePort, new Set()))
|
|
504
|
+
await sleep(500);
|
|
576
505
|
const cfg = readConfig();
|
|
577
506
|
if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
|
|
578
507
|
emit.error({
|
|
@@ -614,12 +543,17 @@ async function upWithCustody(opts) {
|
|
|
614
543
|
allowPaths,
|
|
615
544
|
gateLogPath,
|
|
616
545
|
}, launch);
|
|
617
|
-
const gateStartToken = processStartToken(gateProc.pid);
|
|
618
546
|
await sleep(800);
|
|
619
547
|
// A host CLI that re-executes itself takes longer to load than a bare script,
|
|
620
548
|
// so wait for the gate to actually listen before the provider forwards to it.
|
|
621
549
|
if (!isProcessAlive(gateProc.pid) || !(await waitForPortBound(gatePort, 15_000))) {
|
|
622
|
-
|
|
550
|
+
try {
|
|
551
|
+
process.kill(gateProc.pid);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
/* already dead */
|
|
555
|
+
}
|
|
556
|
+
sweepStrays(gatePort, new Set());
|
|
623
557
|
emit.error({
|
|
624
558
|
code: "tunnel_gate_failed",
|
|
625
559
|
message: `Gate failed to start. Check log: ${gateLogPath}`,
|
|
@@ -629,8 +563,6 @@ async function upWithCustody(opts) {
|
|
|
629
563
|
let url;
|
|
630
564
|
let registered = true;
|
|
631
565
|
let cloudflaredPid;
|
|
632
|
-
let cloudflaredStartToken;
|
|
633
|
-
let pendingReceipt = null;
|
|
634
566
|
if (provider === "cloudflare") {
|
|
635
567
|
// Force HTTP/2 transport. The default QUIC transport wedges at precheck on
|
|
636
568
|
// constrained hosts (e.g. WSL, where UDP receive buffers can't grow and ICMP
|
|
@@ -645,12 +577,21 @@ async function upWithCustody(opts) {
|
|
|
645
577
|
});
|
|
646
578
|
cfdProc.unref();
|
|
647
579
|
cloudflaredPid = cfdProc.pid;
|
|
648
|
-
cloudflaredStartToken = processStartToken(cloudflaredPid) ?? undefined;
|
|
649
580
|
const ready = await waitForReady(providerLogPath, 30_000);
|
|
650
581
|
registered = ready.registered;
|
|
651
582
|
if (!ready.url) {
|
|
652
|
-
|
|
653
|
-
|
|
583
|
+
try {
|
|
584
|
+
process.kill(gateProc.pid);
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
/* already dead */
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
process.kill(cloudflaredPid);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
/* already dead */
|
|
594
|
+
}
|
|
654
595
|
emit.error({
|
|
655
596
|
code: "tunnel_url_timeout",
|
|
656
597
|
message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
|
|
@@ -667,38 +608,13 @@ async function upWithCustody(opts) {
|
|
|
667
608
|
// `heal` would then be unable to see or clean up.
|
|
668
609
|
url = tailscaleUrl(tailscalePath, tailscaleHttpsPort);
|
|
669
610
|
const gateTarget = `http://127.0.0.1:${gatePort}`;
|
|
670
|
-
|
|
671
|
-
name,
|
|
672
|
-
provider,
|
|
673
|
-
url,
|
|
674
|
-
gate_pid: gateProc.pid,
|
|
675
|
-
gate_start_token: gateStartToken ?? undefined,
|
|
676
|
-
started_at: new Date().toISOString(),
|
|
677
|
-
target,
|
|
678
|
-
vhost,
|
|
679
|
-
gate_port: gatePort,
|
|
680
|
-
allow_paths: allowPaths,
|
|
681
|
-
tailscale_mode: tailscaleMode,
|
|
682
|
-
tailscale_path: tailscalePath,
|
|
683
|
-
tailscale_https_port: tailscaleHttpsPort,
|
|
684
|
-
provider_state: "pending",
|
|
685
|
-
};
|
|
686
|
-
pendingReceipt = recordTunnelServer(pending);
|
|
687
|
-
if (!pendingReceipt) {
|
|
688
|
-
const evidence = recordRefusedTunnelLaunch(pending);
|
|
689
|
-
signalIfSameGeneration(pending.gate_pid, pending.gate_start_token, "SIGTERM");
|
|
690
|
-
throw new Error(`Tailscale admission refused before provider effect; retained witnesses at ${evidence}`);
|
|
691
|
-
}
|
|
692
|
-
beginTailscaleRoute(pending, pendingReceipt, () => runTailscaleShare(tailscaleMode, gateTarget, tailscalePath, tailscaleHttpsPort, providerLogPath));
|
|
611
|
+
runTailscaleShare(tailscaleMode, gateTarget, tailscalePath, tailscaleHttpsPort, providerLogPath);
|
|
693
612
|
}
|
|
694
613
|
const state = {
|
|
695
614
|
name,
|
|
696
615
|
provider,
|
|
697
616
|
url,
|
|
698
|
-
...(provider === "tailscale" ? { provider_state: "ready" } : {}),
|
|
699
617
|
gate_pid: gateProc.pid,
|
|
700
|
-
gate_start_token: gateStartToken ?? undefined,
|
|
701
|
-
cloudflared_start_token: cloudflaredStartToken,
|
|
702
618
|
cloudflared_pid: cloudflaredPid,
|
|
703
619
|
started_at: new Date().toISOString(),
|
|
704
620
|
target,
|
|
@@ -709,25 +625,8 @@ async function upWithCustody(opts) {
|
|
|
709
625
|
tailscale_path: tailscalePath,
|
|
710
626
|
tailscale_https_port: tailscaleHttpsPort,
|
|
711
627
|
};
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
state.registration_id = receipt.registration_id;
|
|
715
|
-
try {
|
|
716
|
-
if (!receipt)
|
|
717
|
-
throw new Error("registration refused");
|
|
718
|
-
publishTunnelState(state, receipt);
|
|
719
|
-
}
|
|
720
|
-
catch {
|
|
721
|
-
const evidence = recordRefusedTunnelLaunch(state);
|
|
722
|
-
signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
|
|
723
|
-
if (state.cloudflared_pid)
|
|
724
|
-
signalIfSameGeneration(state.cloudflared_pid, state.cloudflared_start_token, "SIGTERM");
|
|
725
|
-
emit.error({
|
|
726
|
-
code: "tunnel_registration_refused",
|
|
727
|
-
message: `Tunnel [${name}] registration custody was refused; saved process witnesses at ${evidence}.`,
|
|
728
|
-
});
|
|
729
|
-
process.exit(1);
|
|
730
|
-
}
|
|
628
|
+
writeState(state);
|
|
629
|
+
recordTunnelServer(state);
|
|
731
630
|
const bin = resolveBinName();
|
|
732
631
|
const stopHint = name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
|
|
733
632
|
emit.text(`\n Instance: ${name}\n`);
|
|
@@ -755,211 +654,72 @@ async function upWithCustody(opts) {
|
|
|
755
654
|
function registryRoot() {
|
|
756
655
|
return resolveCoordRoot() ?? process.cwd();
|
|
757
656
|
}
|
|
758
|
-
function unknownTunnelFrame(name) {
|
|
759
|
-
return Object.assign(new Error(`Tunnel [${name}] state is unreadable or its custody is unknown; retained the frame.`), {
|
|
760
|
-
code: "tunnel_custody_unknown",
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
function readSelectedState(name) {
|
|
764
|
-
const path = resolve(tunnelDir(), name === DEFAULT_INSTANCE ? "state.json" : `state-${name}.json`);
|
|
765
|
-
let bytes;
|
|
766
|
-
try {
|
|
767
|
-
bytes = readFileSync(path, "utf8");
|
|
768
|
-
}
|
|
769
|
-
catch (error) {
|
|
770
|
-
if (error.code === "ENOENT")
|
|
771
|
-
return null;
|
|
772
|
-
throw unknownTunnelFrame(name);
|
|
773
|
-
}
|
|
774
|
-
try {
|
|
775
|
-
const raw = JSON.parse(bytes);
|
|
776
|
-
if (!raw ||
|
|
777
|
-
typeof raw !== "object" ||
|
|
778
|
-
Array.isArray(raw) ||
|
|
779
|
-
raw.name !== name ||
|
|
780
|
-
(raw.provider !== "cloudflare" && raw.provider !== "tailscale") ||
|
|
781
|
-
(raw.provider === "tailscale" && !originalTailscaleRoute(raw)) ||
|
|
782
|
-
typeof raw.url !== "string" ||
|
|
783
|
-
!raw.url ||
|
|
784
|
-
typeof raw.target !== "string" ||
|
|
785
|
-
!raw.target ||
|
|
786
|
-
typeof raw.vhost !== "string" ||
|
|
787
|
-
!Number.isSafeInteger(raw.gate_pid) ||
|
|
788
|
-
raw.gate_pid < 1 ||
|
|
789
|
-
!Number.isSafeInteger(raw.gate_port) ||
|
|
790
|
-
raw.gate_port < 1 ||
|
|
791
|
-
raw.gate_port > 65535 ||
|
|
792
|
-
!Array.isArray(raw.allow_paths) ||
|
|
793
|
-
raw.allow_paths.some((path) => typeof path !== "string"))
|
|
794
|
-
throw unknownTunnelFrame(name);
|
|
795
|
-
return raw;
|
|
796
|
-
}
|
|
797
|
-
catch {
|
|
798
|
-
throw unknownTunnelFrame(name);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
function listSelectedStates() {
|
|
802
|
-
let files;
|
|
803
|
-
try {
|
|
804
|
-
files = readdirSync(tunnelDir());
|
|
805
|
-
}
|
|
806
|
-
catch (error) {
|
|
807
|
-
if (error.code === "ENOENT")
|
|
808
|
-
return [];
|
|
809
|
-
throw unknownTunnelFrame("inventory");
|
|
810
|
-
}
|
|
811
|
-
const states = [];
|
|
812
|
-
for (const file of files) {
|
|
813
|
-
const name = file === "state.json" ? DEFAULT_INSTANCE : /^state-(.+)\.json$/.exec(file)?.[1];
|
|
814
|
-
if (!name)
|
|
815
|
-
continue;
|
|
816
|
-
const state = readSelectedState(name);
|
|
817
|
-
if (state)
|
|
818
|
-
states.push(state);
|
|
819
|
-
}
|
|
820
|
-
return states.sort((a, b) => (typeof b.started_at === "string" ? b.started_at : "").localeCompare(typeof a.started_at === "string" ? a.started_at : ""));
|
|
821
|
-
}
|
|
822
|
-
function matchesTunnelReceipt(state, receipt) {
|
|
823
|
-
return (originalProcessWitnesses(state) &&
|
|
824
|
-
tunnelServerId(state.name) === receipt.id &&
|
|
825
|
-
receipt.host === hostname() &&
|
|
826
|
-
receipt.kind === "service" &&
|
|
827
|
-
receipt.type === "tunnel" &&
|
|
828
|
-
receipt.port === state.gate_port &&
|
|
829
|
-
receipt.url === state.url &&
|
|
830
|
-
receipt.registration_id === state.registration_id &&
|
|
831
|
-
receipt.pid === state.gate_pid &&
|
|
832
|
-
receipt.start_token === state.gate_start_token);
|
|
833
|
-
}
|
|
834
|
-
/** Retain a refused launch without overwriting another launch's canonical state. */
|
|
835
|
-
export function recordRefusedTunnelLaunch(state) {
|
|
836
|
-
const path = resolve(tunnelDir(), `failed-${state.name}-${randomUUID()}.json`);
|
|
837
|
-
writePrivateJsonAtomic(path, state);
|
|
838
|
-
return path;
|
|
839
|
-
}
|
|
840
|
-
/** A canonical state frame may be published only by its current registration. */
|
|
841
|
-
export function publishTunnelState(state, receipt) {
|
|
842
|
-
if (!matchesTunnelReceipt(state, receipt))
|
|
843
|
-
throw new Error("tunnel state registration custody does not match");
|
|
844
|
-
withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, () => writeState(state));
|
|
845
|
-
}
|
|
846
|
-
/** Retain pending route intent before asking the provider to change external state. */
|
|
847
|
-
export function beginTailscaleRoute(state, receipt, effect) {
|
|
848
|
-
if (state.provider !== "tailscale" || state.provider_state !== "pending")
|
|
849
|
-
throw new Error("Tailscale route intent is not pending");
|
|
850
|
-
publishTunnelState(state, receipt);
|
|
851
|
-
return withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, effect);
|
|
852
|
-
}
|
|
853
657
|
function recordTunnelServer(state) {
|
|
854
|
-
if (!state.gate_start_token)
|
|
855
|
-
return null;
|
|
856
658
|
const coordRoot = registryRoot();
|
|
857
|
-
|
|
858
|
-
if (record)
|
|
859
|
-
state.registration_id = record.registration_id;
|
|
860
|
-
return record;
|
|
659
|
+
tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
|
|
861
660
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
const coordRoot = registryRoot();
|
|
866
|
-
const id = tunnelServerId(state.name);
|
|
867
|
-
let record;
|
|
868
|
-
try {
|
|
869
|
-
record = readServer(id, { coordRoot });
|
|
870
|
-
}
|
|
871
|
-
catch {
|
|
872
|
-
return { known: false, record: null };
|
|
873
|
-
}
|
|
874
|
-
const present = existsSync(resolve(serversDir(coordRoot), `${id}.json`));
|
|
875
|
-
if (!present)
|
|
876
|
-
return { known: false, record: null };
|
|
877
|
-
return { known: !!record && matchesTunnelReceipt(state, record), record };
|
|
661
|
+
/** Tear down a single instance by name. Returns the number of processes killed. */
|
|
662
|
+
function portHeldByOtherLiveInstance(port, name) {
|
|
663
|
+
return listStates().some((s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid));
|
|
878
664
|
}
|
|
879
|
-
/** Unknown saved identities retain state and never authorize a signal. */
|
|
880
665
|
export function downOne(name, killed) {
|
|
881
|
-
try {
|
|
882
|
-
return withTunnelLifecycleCustody(name, null, () => downOneWithCustody(name, killed));
|
|
883
|
-
}
|
|
884
|
-
catch {
|
|
885
|
-
return 0;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
function downOneWithCustody(name, killed) {
|
|
889
666
|
const before = killed.size;
|
|
890
|
-
const state =
|
|
891
|
-
if (
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
return 0;
|
|
906
|
-
let cleared = false;
|
|
907
|
-
try {
|
|
908
|
-
withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, () => {
|
|
909
|
-
for (const target of targets) {
|
|
910
|
-
if (!pidAlive(target.pid) || killed.has(target.pid))
|
|
911
|
-
continue;
|
|
912
|
-
const result = signalIfSameGeneration(target.pid, target.token, "SIGTERM");
|
|
913
|
-
if (!result.sent)
|
|
914
|
-
throw new Error("tunnel process custody is unknown");
|
|
915
|
-
killed.add(target.pid);
|
|
667
|
+
const state = readState(name);
|
|
668
|
+
if (state) {
|
|
669
|
+
if (state.provider === "tailscale")
|
|
670
|
+
stopTailscaleShare(state);
|
|
671
|
+
for (const pid of [state.gate_pid, state.cloudflared_pid, state.provider_pid]) {
|
|
672
|
+
if (typeof pid !== "number")
|
|
673
|
+
continue;
|
|
674
|
+
if (isProcessAlive(pid)) {
|
|
675
|
+
try {
|
|
676
|
+
process.kill(pid);
|
|
677
|
+
killed.add(pid);
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
/* race: already gone */
|
|
681
|
+
}
|
|
916
682
|
}
|
|
917
|
-
|
|
918
|
-
if (targets.some((target) => pidAlive(target.pid)))
|
|
919
|
-
return;
|
|
920
|
-
if (JSON.stringify(readSelectedState(name)) !== JSON.stringify(state))
|
|
921
|
-
return;
|
|
922
|
-
if (state.provider === "tailscale" && !stopTailscaleShare(state))
|
|
923
|
-
return;
|
|
924
|
-
clearState(name);
|
|
925
|
-
cleared = true;
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
catch {
|
|
929
|
-
return killed.size - before;
|
|
683
|
+
}
|
|
930
684
|
}
|
|
931
|
-
|
|
932
|
-
|
|
685
|
+
// Fallback: sweep orphans on this instance's gate port, even when state was
|
|
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);
|
|
692
|
+
clearState(name);
|
|
693
|
+
unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
|
|
933
694
|
return killed.size - before;
|
|
934
695
|
}
|
|
935
696
|
function down(opts) {
|
|
936
697
|
const killed = new Set();
|
|
937
698
|
if (opts.all) {
|
|
938
|
-
const states =
|
|
699
|
+
const states = listStates();
|
|
939
700
|
if (states.length === 0) {
|
|
940
701
|
emit.text("No tunnels up. Nothing to stop.\n");
|
|
941
702
|
return;
|
|
942
703
|
}
|
|
943
704
|
for (const s of states)
|
|
944
705
|
downOne(s.name, killed);
|
|
945
|
-
|
|
946
|
-
emit.text(`Requested stop for ${states.length} tunnel(s); ${killed.size} process signal(s), ${retained} state record(s) retained pending verified exit.\n`);
|
|
706
|
+
emit.text(`Stopped ${states.length} tunnel(s) [${states.map((s) => s.name).join(", ")}], ${killed.size} process(es).\n`);
|
|
947
707
|
return;
|
|
948
708
|
}
|
|
949
709
|
const name = resolveName(opts.name);
|
|
950
710
|
// Bare `down` targets the default instance. If it's not up but named ones
|
|
951
711
|
// are, don't silently no-op; point the operator at them.
|
|
952
|
-
if (name === DEFAULT_INSTANCE && !
|
|
953
|
-
const others =
|
|
712
|
+
if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
|
|
713
|
+
const others = listStates();
|
|
954
714
|
if (others.length > 0) {
|
|
955
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`);
|
|
956
716
|
return;
|
|
957
717
|
}
|
|
958
718
|
}
|
|
959
719
|
downOne(name, killed);
|
|
960
|
-
emit.text(
|
|
961
|
-
? `
|
|
962
|
-
: `Tunnel [${name}]
|
|
720
|
+
emit.text(killed.size === 0
|
|
721
|
+
? `No tunnel processes found for [${name}]. Nothing to stop.\n`
|
|
722
|
+
: `Stopped ${killed.size} process(es). Tunnel [${name}] down.\n`);
|
|
963
723
|
}
|
|
964
724
|
/** Poll until `port` has no LISTEN socket (or the deadline passes). */
|
|
965
725
|
async function waitForPortFree(port, timeoutMs) {
|
|
@@ -993,25 +753,9 @@ async function waitForPortBound(port, timeoutMs) {
|
|
|
993
753
|
* edge reconnects without noticing.
|
|
994
754
|
*/
|
|
995
755
|
export async function reloadOne(state) {
|
|
996
|
-
try {
|
|
997
|
-
return await withTunnelLifecycleCustody(state.name, routeOf(state), () => reloadOneWithCustody(state));
|
|
998
|
-
}
|
|
999
|
-
catch {
|
|
1000
|
-
return {
|
|
1001
|
-
ok: false,
|
|
1002
|
-
message: `[${state.name}] lifecycle or provider route custody is unavailable; retained the tunnel.`,
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
async function reloadOneWithCustody(state) {
|
|
1007
756
|
const bin = resolveBinName();
|
|
1008
757
|
const name = state.name;
|
|
1009
758
|
const gatePort = state.gate_port;
|
|
1010
|
-
if (!canonicalProviderFields(state))
|
|
1011
|
-
return {
|
|
1012
|
-
ok: false,
|
|
1013
|
-
message: `[${name}] obsolete or ambiguous provider authority is unknown; retained the tunnel.`,
|
|
1014
|
-
};
|
|
1015
759
|
if (!providerIsAlive(state)) {
|
|
1016
760
|
return {
|
|
1017
761
|
ok: false,
|
|
@@ -1034,51 +778,27 @@ async function reloadOneWithCustody(state) {
|
|
|
1034
778
|
`\`${bin} tunnel up --name ${name} --allow-path <prefix>\`.`,
|
|
1035
779
|
};
|
|
1036
780
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
if (pidAlive(state.gate_pid)) {
|
|
1049
|
-
if (!registration.record)
|
|
1050
|
-
return {
|
|
1051
|
-
ok: false,
|
|
1052
|
-
message: `[${name}] registration custody is unavailable; retained the tunnel.`,
|
|
1053
|
-
};
|
|
1054
|
-
const result = signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
|
|
1055
|
-
if (!result.sent)
|
|
1056
|
-
return {
|
|
1057
|
-
ok: false,
|
|
1058
|
-
message: `[${name}] gate generation is unknown or changed; retained the tunnel.`,
|
|
1059
|
-
};
|
|
781
|
+
// Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
|
|
782
|
+
// matches `--url http://localhost:<port>`, which is the provider process we
|
|
783
|
+
// are going out of our way to keep alive.
|
|
784
|
+
const killed = new Set();
|
|
785
|
+
if (isProcessAlive(state.gate_pid)) {
|
|
786
|
+
try {
|
|
787
|
+
process.kill(state.gate_pid);
|
|
788
|
+
killed.add(state.gate_pid);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
/* race: already gone */
|
|
1060
792
|
}
|
|
1061
|
-
if (!(await waitForPortFree(gatePort, 5_000)))
|
|
1062
|
-
return {
|
|
1063
|
-
ok: false,
|
|
1064
|
-
message: `[${name}] port ${gatePort} never released; retained the tunnel state.`,
|
|
1065
|
-
};
|
|
1066
|
-
return { ok: true, message: "gate released" };
|
|
1067
|
-
};
|
|
1068
|
-
let released;
|
|
1069
|
-
try {
|
|
1070
|
-
released = await withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, stopGate);
|
|
1071
793
|
}
|
|
1072
|
-
|
|
794
|
+
killByPattern(gatePattern(gatePort), killed);
|
|
795
|
+
if (!(await waitForPortFree(gatePort, 5_000))) {
|
|
1073
796
|
return {
|
|
1074
797
|
ok: false,
|
|
1075
|
-
message: `[${name}]
|
|
798
|
+
message: `[${name}] port ${gatePort} never released, so the old gate looks wedged. ` +
|
|
799
|
+
`Tunnel is down; check \`${bin} tunnel status\`.`,
|
|
1076
800
|
};
|
|
1077
801
|
}
|
|
1078
|
-
if (!released.ok)
|
|
1079
|
-
return released;
|
|
1080
|
-
if (registration.record)
|
|
1081
|
-
unregisterServer(registration.record, { coordRoot: registryRoot() });
|
|
1082
802
|
const cfg = readConfig();
|
|
1083
803
|
const gateProc = spawnGate({
|
|
1084
804
|
name,
|
|
@@ -1090,7 +810,6 @@ async function reloadOneWithCustody(state) {
|
|
|
1090
810
|
allowPaths: state.allow_paths,
|
|
1091
811
|
gateLogPath: tunnelLogDestinations(name, state.provider).gate,
|
|
1092
812
|
}, launch);
|
|
1093
|
-
const gateStartToken = processStartToken(gateProc.pid);
|
|
1094
813
|
await sleep(800);
|
|
1095
814
|
const pid = gateProc.pid;
|
|
1096
815
|
if (typeof pid !== "number" ||
|
|
@@ -1102,33 +821,15 @@ async function reloadOneWithCustody(state) {
|
|
|
1102
821
|
`Tunnel is down; check \`${bin} tunnel logs --name ${name}\`.`,
|
|
1103
822
|
};
|
|
1104
823
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
gate_pid: pid,
|
|
1108
|
-
gate_start_token: gateStartToken ?? undefined,
|
|
1109
|
-
registration_id: undefined,
|
|
1110
|
-
};
|
|
1111
|
-
const nextReceipt = recordTunnelServer(next);
|
|
1112
|
-
try {
|
|
1113
|
-
if (!nextReceipt)
|
|
1114
|
-
throw new Error("registration refused");
|
|
1115
|
-
publishTunnelState(next, nextReceipt);
|
|
1116
|
-
}
|
|
1117
|
-
catch {
|
|
1118
|
-
const evidence = recordRefusedTunnelLaunch(next);
|
|
1119
|
-
signalIfSameGeneration(pid, gateStartToken, "SIGTERM");
|
|
1120
|
-
return {
|
|
1121
|
-
ok: false,
|
|
1122
|
-
message: `[${name}] new gate registration custody was refused; retained witnesses at ${evidence}.`,
|
|
1123
|
-
};
|
|
1124
|
-
}
|
|
824
|
+
writeState({ ...state, gate_pid: pid });
|
|
825
|
+
recordTunnelServer({ ...state, gate_pid: pid });
|
|
1125
826
|
const allowNote = state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
|
|
1126
827
|
return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
|
|
1127
828
|
}
|
|
1128
829
|
async function reload(opts) {
|
|
1129
830
|
const bin = resolveBinName();
|
|
1130
831
|
if (opts.all) {
|
|
1131
|
-
const all =
|
|
832
|
+
const all = listStates();
|
|
1132
833
|
// Only live tunnels are reloadable, and a box commonly accumulates stale
|
|
1133
834
|
// state files from long-dead runs. Reporting those as failures would make
|
|
1134
835
|
// the common post-`allow add` path exit non-zero for no real reason.
|
|
@@ -1154,7 +855,7 @@ async function reload(opts) {
|
|
|
1154
855
|
return;
|
|
1155
856
|
}
|
|
1156
857
|
const name = resolveName(opts.name);
|
|
1157
|
-
const state =
|
|
858
|
+
const state = readState(name);
|
|
1158
859
|
if (!state) {
|
|
1159
860
|
emit.error({
|
|
1160
861
|
code: "tunnel_not_found",
|
|
@@ -1169,8 +870,7 @@ async function reload(opts) {
|
|
|
1169
870
|
process.exit(1);
|
|
1170
871
|
}
|
|
1171
872
|
function instanceState(state) {
|
|
1172
|
-
|
|
1173
|
-
return readiness === "ready" ? "up" : readiness === "dead" ? "stale" : "unknown";
|
|
873
|
+
return tunnelIsAlive(state) ? "up" : "stale";
|
|
1174
874
|
}
|
|
1175
875
|
function fmtUptime(startedAt) {
|
|
1176
876
|
const secs = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
|
|
@@ -1186,9 +886,9 @@ function fmtUptime(startedAt) {
|
|
|
1186
886
|
function statusDetail(state) {
|
|
1187
887
|
const gateAlive = isProcessAlive(state.gate_pid);
|
|
1188
888
|
const providerAlive = providerIsAlive(state);
|
|
1189
|
-
const providerPid = state.cloudflared_pid;
|
|
889
|
+
const providerPid = state.cloudflared_pid ?? state.provider_pid;
|
|
1190
890
|
const cfg = readConfig();
|
|
1191
|
-
emit.text(`${
|
|
891
|
+
emit.text(`${gateAlive && providerAlive ? "up" : "stale"} [${state.name}]\n`);
|
|
1192
892
|
emit.text(` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`);
|
|
1193
893
|
emit.text(` URL: ${state.url}\n`);
|
|
1194
894
|
emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
|
|
@@ -1209,7 +909,7 @@ function statusDetail(state) {
|
|
|
1209
909
|
function status(opts) {
|
|
1210
910
|
// Named → detailed single block.
|
|
1211
911
|
if (opts.name) {
|
|
1212
|
-
const state =
|
|
912
|
+
const state = readState(resolveName(opts.name));
|
|
1213
913
|
if (!state) {
|
|
1214
914
|
emit.text(`down [${resolveName(opts.name)}]\n`);
|
|
1215
915
|
return;
|
|
@@ -1218,7 +918,7 @@ function status(opts) {
|
|
|
1218
918
|
return;
|
|
1219
919
|
}
|
|
1220
920
|
// No name → table of every instance.
|
|
1221
|
-
const states =
|
|
921
|
+
const states = listStates();
|
|
1222
922
|
if (states.length === 0) {
|
|
1223
923
|
emit.text("down\n");
|
|
1224
924
|
return;
|
|
@@ -1255,7 +955,7 @@ function status(opts) {
|
|
|
1255
955
|
}
|
|
1256
956
|
function logs(opts) {
|
|
1257
957
|
const name = resolveName(opts.name);
|
|
1258
|
-
const state =
|
|
958
|
+
const state = readState(name);
|
|
1259
959
|
const provider = state?.provider ?? "cloudflare";
|
|
1260
960
|
const destinations = tunnelLogDestinations(name, provider);
|
|
1261
961
|
const path = opts.provider ? destinations.provider : destinations.gate;
|
|
@@ -1289,7 +989,7 @@ const sameEntryIn = (list, entry) => list.some((e) => canonicalEntry(e) === cano
|
|
|
1289
989
|
* restarts were owed.
|
|
1290
990
|
*/
|
|
1291
991
|
function allowChangedHint() {
|
|
1292
|
-
const live =
|
|
992
|
+
const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
|
|
1293
993
|
if (live.length === 0)
|
|
1294
994
|
return;
|
|
1295
995
|
emit.text(`Each gate reads the allowlist once at start, so this applies on reload ` +
|
|
@@ -1396,7 +1096,7 @@ export async function refreshCurrentAddress(options) {
|
|
|
1396
1096
|
}
|
|
1397
1097
|
if (!plan.changed || !options.reload)
|
|
1398
1098
|
return result;
|
|
1399
|
-
const live =
|
|
1099
|
+
const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
|
|
1400
1100
|
for (const s of live) {
|
|
1401
1101
|
const r = await reloadOne(s);
|
|
1402
1102
|
emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
|