harnery 0.44.0 → 0.45.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.js +1 -0
- package/dist/commands/files.d.ts +2 -2
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +7 -4
- package/dist/commands/servers.d.ts.map +1 -1
- package/dist/commands/servers.js +11 -17
- package/dist/commands/tunnel.d.ts +15 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +453 -153
- package/dist/commands/turn-rollback.d.ts +5 -0
- package/dist/commands/turn-rollback.d.ts.map +1 -0
- package/dist/commands/turn-rollback.js +76 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +44 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +18 -0
- package/dist/core/agents/state/proc-start.d.ts +5 -6
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +13 -9
- package/dist/core/agents/state/process-handle.d.ts +26 -0
- package/dist/core/agents/state/process-handle.d.ts.map +1 -0
- package/dist/core/agents/state/process-handle.js +121 -0
- package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
- package/dist/core/artifacts/delivery-card.js +2 -1
- package/dist/core/resources/service.d.ts +2 -16
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +1 -78
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +2 -9
- package/dist/core/servers/index.d.ts +12 -11
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +194 -81
- package/dist/core/servers/tunnels.d.ts +5 -3
- package/dist/core/servers/tunnels.d.ts.map +1 -1
- package/dist/core/servers/tunnels.js +45 -2
- package/dist/core/supervisor/service.d.ts +2 -4
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/turn-rollback/content.d.ts +53 -0
- package/dist/core/turn-rollback/content.d.ts.map +1 -0
- package/dist/core/turn-rollback/content.js +396 -0
- package/dist/core/turn-rollback/index.d.ts +72 -0
- package/dist/core/turn-rollback/index.d.ts.map +1 -0
- package/dist/core/turn-rollback/index.js +443 -0
- package/dist/core/workflow/index.d.ts +4 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/recovery-audit.d.ts +30 -0
- package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
- package/dist/core/workflow/recovery-audit.js +116 -0
- package/dist/core/workflow/run-state.d.ts +19 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +113 -8
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +70 -14
- package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +24 -0
- package/dist/lib/tunnel/state.d.ts +13 -6
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +58 -12
- package/package.json +6 -1
- package/src/commander.ts +10 -0
- package/src/commands/files.ts +13 -5
- package/src/commands/servers.ts +12 -23
- package/src/commands/tunnel.ts +533 -166
- package/src/commands/turn-rollback.ts +111 -0
- package/src/commands/workflow.ts +59 -0
- package/src/core/agents/coord-client.ts +20 -0
- package/src/core/agents/state/proc-start.ts +14 -11
- package/src/core/agents/state/process-handle.ts +165 -0
- package/src/core/artifacts/delivery-card.ts +7 -1
- package/src/core/resources/service.ts +3 -88
- package/src/core/semantic/service.ts +2 -8
- package/src/core/servers/index.ts +234 -85
- package/src/core/servers/tunnels.ts +55 -3
- package/src/core/supervisor/service.ts +2 -3
- package/src/core/turn-rollback/content.ts +499 -0
- package/src/core/turn-rollback/index.ts +580 -0
- package/src/core/workflow/index.ts +12 -1
- package/src/core/workflow/recovery-audit.ts +181 -0
- package/src/core/workflow/run-state.ts +137 -8
- package/src/core/workflow/workspaces/local-git.ts +86 -15
- package/src/core/workflow/workspaces/reclaim.ts +4 -0
- package/src/core/workflow/workspaces/recovery.ts +23 -0
- package/src/lib/tunnel/state.ts +70 -15
package/dist/commands/tunnel.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { hostname } from "node:os";
|
|
3
5
|
import { resolve } from "node:path";
|
|
4
6
|
import { resolveCoordRoot } from "../core/agents/coord-client.js";
|
|
5
7
|
import { resolveBinName } from "../core/config.js";
|
|
6
|
-
import { tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, } from "../core/servers/index.js";
|
|
8
|
+
import { acquireNoClobberLease, createTunnelReadinessVerifier, pidAlive, processStartToken, readServer, serversDir, signalIfSameGeneration, tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, verifyProcessGeneration, withServerRegistrationCustody, } from "../core/servers/index.js";
|
|
9
|
+
import { writePrivateJsonAtomic } from "../core/storage/atomic-json.js";
|
|
7
10
|
import { processLogDestination, runProcessLogWorker, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
|
|
8
11
|
import { normalizeAllowEntry } from "../lib/tunnel/allowlist.js";
|
|
9
12
|
import { detectPublicAddresses, planCurrentAddresses, } from "../lib/tunnel/current-address.js";
|
|
10
13
|
import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.js";
|
|
11
|
-
import { connectProbe,
|
|
12
|
-
import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive,
|
|
14
|
+
import { connectProbe, listeningPorts, portListening } from "../lib/tunnel/processes.js";
|
|
15
|
+
import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, providerLogFile, readConfig, tunnelDir, tunnelReadiness, writeConfig, writeState, } from "../lib/tunnel/state.js";
|
|
13
16
|
/**
|
|
14
17
|
* `tunnel`: provider-backed tunnel in front of a local upstream.
|
|
15
18
|
*
|
|
@@ -218,57 +221,13 @@ function tailscaleAvailable() {
|
|
|
218
221
|
function sleep(ms) {
|
|
219
222
|
return new Promise((r) => setTimeout(r, ms));
|
|
220
223
|
}
|
|
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
|
-
}
|
|
265
224
|
/**
|
|
266
225
|
* Pick a gate port for a new instance. An explicit `--gate-port` is honored
|
|
267
226
|
* (and rejected if it's already taken); otherwise scan upward from 9001 for the
|
|
268
227
|
* first port that's neither held by a live instance nor currently listening.
|
|
269
228
|
*/
|
|
270
229
|
async function allocateGatePort(preferred) {
|
|
271
|
-
const used = new Set(
|
|
230
|
+
const used = new Set(listSelectedStates()
|
|
272
231
|
.filter((s) => isProcessAlive(s.gate_pid))
|
|
273
232
|
.map((s) => s.gate_port));
|
|
274
233
|
// No discovery tool (null) is not "every port free": probe candidates by connecting.
|
|
@@ -383,14 +342,16 @@ function runTailscaleShare(mode, targetUrl, path, httpsPort, logPath) {
|
|
|
383
342
|
}
|
|
384
343
|
}
|
|
385
344
|
function stopTailscaleShare(state) {
|
|
386
|
-
if (state.provider !== "tailscale"
|
|
345
|
+
if (state.provider !== "tailscale")
|
|
387
346
|
return true;
|
|
347
|
+
if (!originalTailscaleRoute(state))
|
|
348
|
+
return false;
|
|
388
349
|
const cmd = tailscaleCommand(state.tailscale_mode);
|
|
389
350
|
const logPath = tunnelLogDestinations(state.name, "tailscale").provider;
|
|
390
351
|
const args = [
|
|
391
352
|
cmd,
|
|
392
|
-
`--https=${state.tailscale_https_port
|
|
393
|
-
`--set-path=${state.tailscale_path
|
|
353
|
+
`--https=${state.tailscale_https_port}`,
|
|
354
|
+
`--set-path=${state.tailscale_path}`,
|
|
394
355
|
"off",
|
|
395
356
|
];
|
|
396
357
|
const status = runRotatingProcessSync({
|
|
@@ -402,9 +363,9 @@ function stopTailscaleShare(state) {
|
|
|
402
363
|
// The gate is torn down and its port is freed for reuse regardless, so a
|
|
403
364
|
// surviving serve/funnel mapping could later re-expose whatever next binds
|
|
404
365
|
// that port. Surface it loudly so the operator can clear it by hand.
|
|
405
|
-
emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path
|
|
366
|
+
emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path}).\n` +
|
|
406
367
|
` It may still be exposing this machine. Clear it with:\n` +
|
|
407
|
-
` tailscale ${cmd} --https=${state.tailscale_https_port
|
|
368
|
+
` tailscale ${cmd} --https=${state.tailscale_https_port} --set-path=${state.tailscale_path} off\n` +
|
|
408
369
|
` Log: ${logPath}\n\n`);
|
|
409
370
|
return false;
|
|
410
371
|
}
|
|
@@ -417,13 +378,39 @@ function contextVhost() {
|
|
|
417
378
|
return resolved ?? null;
|
|
418
379
|
}
|
|
419
380
|
function providerIsAlive(state) {
|
|
381
|
+
if (!canonicalProviderFields(state))
|
|
382
|
+
return false;
|
|
420
383
|
if (state.provider === "tailscale")
|
|
421
|
-
return
|
|
422
|
-
const pid = state.cloudflared_pid
|
|
384
|
+
return state.provider_state === "ready";
|
|
385
|
+
const pid = state.cloudflared_pid;
|
|
423
386
|
return typeof pid === "number" && isProcessAlive(pid);
|
|
424
387
|
}
|
|
425
|
-
function
|
|
426
|
-
return
|
|
388
|
+
function canonicalProviderFields(state) {
|
|
389
|
+
return (!Object.hasOwn(state, "provider_pid") &&
|
|
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() }));
|
|
427
414
|
}
|
|
428
415
|
/**
|
|
429
416
|
* Spawn the detached gate worker.
|
|
@@ -434,8 +421,7 @@ function tunnelIsAlive(state) {
|
|
|
434
421
|
* re-reads — that snapshot is precisely why `reload` has to exist.
|
|
435
422
|
*/
|
|
436
423
|
function spawnGate(o, launch) {
|
|
437
|
-
//
|
|
438
|
-
// process distinguishable per-instance in a command-line search (see sweepStrays).
|
|
424
|
+
// Keep the instance name and port visible in process diagnostics.
|
|
439
425
|
const gateProc = spawnRotatingProcess({
|
|
440
426
|
path: o.gateLogPath,
|
|
441
427
|
command: launch.command,
|
|
@@ -454,7 +440,79 @@ function spawnGate(o, launch) {
|
|
|
454
440
|
gateProc.unref();
|
|
455
441
|
return gateProc;
|
|
456
442
|
}
|
|
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
|
+
}
|
|
457
508
|
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) {
|
|
458
516
|
const launch = currentGateLaunch();
|
|
459
517
|
if (!launch) {
|
|
460
518
|
emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
|
|
@@ -470,8 +528,17 @@ async function up(opts) {
|
|
|
470
528
|
const tailscalePath = provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
|
|
471
529
|
const tailscaleHttpsPort = provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
|
|
472
530
|
const allowPaths = resolveAllowPaths(opts.allowPath);
|
|
473
|
-
const existing =
|
|
474
|
-
|
|
531
|
+
const existing = readSelectedState(name);
|
|
532
|
+
const readiness = existing ? readinessOf(existing) : null;
|
|
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") {
|
|
475
542
|
if (existing.provider !== provider) {
|
|
476
543
|
emit.error({
|
|
477
544
|
code: "tunnel_instance_in_use",
|
|
@@ -493,15 +560,19 @@ async function up(opts) {
|
|
|
493
560
|
emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
|
|
494
561
|
return;
|
|
495
562
|
}
|
|
496
|
-
if (existing)
|
|
497
|
-
|
|
563
|
+
if (existing) {
|
|
564
|
+
downOneWithCustody(name, new Set());
|
|
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
|
+
}
|
|
498
573
|
// Allocate the gate port (after clearing dead state so its old port frees up
|
|
499
574
|
// for reuse). Explicit --gate-port is validated; otherwise auto-scan.
|
|
500
575
|
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);
|
|
505
576
|
const cfg = readConfig();
|
|
506
577
|
if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
|
|
507
578
|
emit.error({
|
|
@@ -543,17 +614,12 @@ async function up(opts) {
|
|
|
543
614
|
allowPaths,
|
|
544
615
|
gateLogPath,
|
|
545
616
|
}, launch);
|
|
617
|
+
const gateStartToken = processStartToken(gateProc.pid);
|
|
546
618
|
await sleep(800);
|
|
547
619
|
// A host CLI that re-executes itself takes longer to load than a bare script,
|
|
548
620
|
// so wait for the gate to actually listen before the provider forwards to it.
|
|
549
621
|
if (!isProcessAlive(gateProc.pid) || !(await waitForPortBound(gatePort, 15_000))) {
|
|
550
|
-
|
|
551
|
-
process.kill(gateProc.pid);
|
|
552
|
-
}
|
|
553
|
-
catch {
|
|
554
|
-
/* already dead */
|
|
555
|
-
}
|
|
556
|
-
sweepStrays(gatePort, new Set());
|
|
622
|
+
signalIfSameGeneration(gateProc.pid, gateStartToken, "SIGTERM");
|
|
557
623
|
emit.error({
|
|
558
624
|
code: "tunnel_gate_failed",
|
|
559
625
|
message: `Gate failed to start. Check log: ${gateLogPath}`,
|
|
@@ -563,6 +629,8 @@ async function up(opts) {
|
|
|
563
629
|
let url;
|
|
564
630
|
let registered = true;
|
|
565
631
|
let cloudflaredPid;
|
|
632
|
+
let cloudflaredStartToken;
|
|
633
|
+
let pendingReceipt = null;
|
|
566
634
|
if (provider === "cloudflare") {
|
|
567
635
|
// Force HTTP/2 transport. The default QUIC transport wedges at precheck on
|
|
568
636
|
// constrained hosts (e.g. WSL, where UDP receive buffers can't grow and ICMP
|
|
@@ -577,21 +645,12 @@ async function up(opts) {
|
|
|
577
645
|
});
|
|
578
646
|
cfdProc.unref();
|
|
579
647
|
cloudflaredPid = cfdProc.pid;
|
|
648
|
+
cloudflaredStartToken = processStartToken(cloudflaredPid) ?? undefined;
|
|
580
649
|
const ready = await waitForReady(providerLogPath, 30_000);
|
|
581
650
|
registered = ready.registered;
|
|
582
651
|
if (!ready.url) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
catch {
|
|
587
|
-
/* already dead */
|
|
588
|
-
}
|
|
589
|
-
try {
|
|
590
|
-
process.kill(cloudflaredPid);
|
|
591
|
-
}
|
|
592
|
-
catch {
|
|
593
|
-
/* already dead */
|
|
594
|
-
}
|
|
652
|
+
signalIfSameGeneration(gateProc.pid, gateStartToken, "SIGTERM");
|
|
653
|
+
signalIfSameGeneration(cloudflaredPid, cloudflaredStartToken, "SIGTERM");
|
|
595
654
|
emit.error({
|
|
596
655
|
code: "tunnel_url_timeout",
|
|
597
656
|
message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
|
|
@@ -608,13 +667,38 @@ async function up(opts) {
|
|
|
608
667
|
// `heal` would then be unable to see or clean up.
|
|
609
668
|
url = tailscaleUrl(tailscalePath, tailscaleHttpsPort);
|
|
610
669
|
const gateTarget = `http://127.0.0.1:${gatePort}`;
|
|
611
|
-
|
|
670
|
+
const pending = {
|
|
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));
|
|
612
693
|
}
|
|
613
694
|
const state = {
|
|
614
695
|
name,
|
|
615
696
|
provider,
|
|
616
697
|
url,
|
|
698
|
+
...(provider === "tailscale" ? { provider_state: "ready" } : {}),
|
|
617
699
|
gate_pid: gateProc.pid,
|
|
700
|
+
gate_start_token: gateStartToken ?? undefined,
|
|
701
|
+
cloudflared_start_token: cloudflaredStartToken,
|
|
618
702
|
cloudflared_pid: cloudflaredPid,
|
|
619
703
|
started_at: new Date().toISOString(),
|
|
620
704
|
target,
|
|
@@ -625,8 +709,25 @@ async function up(opts) {
|
|
|
625
709
|
tailscale_path: tailscalePath,
|
|
626
710
|
tailscale_https_port: tailscaleHttpsPort,
|
|
627
711
|
};
|
|
628
|
-
|
|
629
|
-
|
|
712
|
+
const receipt = provider === "tailscale" ? pendingReceipt : recordTunnelServer(state);
|
|
713
|
+
if (receipt)
|
|
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
|
+
}
|
|
630
731
|
const bin = resolveBinName();
|
|
631
732
|
const stopHint = name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
|
|
632
733
|
emit.text(`\n Instance: ${name}\n`);
|
|
@@ -654,72 +755,211 @@ async function up(opts) {
|
|
|
654
755
|
function registryRoot() {
|
|
655
756
|
return resolveCoordRoot() ?? process.cwd();
|
|
656
757
|
}
|
|
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
|
+
}
|
|
657
853
|
function recordTunnelServer(state) {
|
|
854
|
+
if (!state.gate_start_token)
|
|
855
|
+
return null;
|
|
658
856
|
const coordRoot = registryRoot();
|
|
659
|
-
tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
|
|
857
|
+
const record = tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
|
|
858
|
+
if (record)
|
|
859
|
+
state.registration_id = record.registration_id;
|
|
860
|
+
return record;
|
|
660
861
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
862
|
+
function tunnelRegistration(state) {
|
|
863
|
+
if (!originalProcessWitnesses(state))
|
|
864
|
+
return { known: false, record: null };
|
|
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 };
|
|
664
878
|
}
|
|
879
|
+
/** Unknown saved identities retain state and never authorize a signal. */
|
|
665
880
|
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) {
|
|
666
889
|
const before = killed.size;
|
|
667
|
-
const state =
|
|
668
|
-
if (state)
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
890
|
+
const state = readSelectedState(name);
|
|
891
|
+
if (!state)
|
|
892
|
+
return 0;
|
|
893
|
+
const registration = tunnelRegistration(state);
|
|
894
|
+
if (!registration.known)
|
|
895
|
+
return 0;
|
|
896
|
+
const targets = [
|
|
897
|
+
{ pid: state.gate_pid, token: state.gate_start_token },
|
|
898
|
+
{ pid: state.cloudflared_pid, token: state.cloudflared_start_token },
|
|
899
|
+
].filter((target) => typeof target.pid === "number");
|
|
900
|
+
for (const target of targets) {
|
|
901
|
+
if (pidAlive(target.pid) && verifyProcessGeneration(target.pid, target.token) !== "same")
|
|
902
|
+
return 0;
|
|
903
|
+
}
|
|
904
|
+
if (!registration.record)
|
|
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);
|
|
682
916
|
}
|
|
683
|
-
|
|
917
|
+
// Observation and deletion share custody; an old stopper cannot clear a winner.
|
|
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;
|
|
684
930
|
}
|
|
685
|
-
|
|
686
|
-
|
|
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() });
|
|
931
|
+
if (cleared)
|
|
932
|
+
unregisterServer(registration.record, { coordRoot: registryRoot() });
|
|
694
933
|
return killed.size - before;
|
|
695
934
|
}
|
|
696
935
|
function down(opts) {
|
|
697
936
|
const killed = new Set();
|
|
698
937
|
if (opts.all) {
|
|
699
|
-
const states =
|
|
938
|
+
const states = listSelectedStates();
|
|
700
939
|
if (states.length === 0) {
|
|
701
940
|
emit.text("No tunnels up. Nothing to stop.\n");
|
|
702
941
|
return;
|
|
703
942
|
}
|
|
704
943
|
for (const s of states)
|
|
705
944
|
downOne(s.name, killed);
|
|
706
|
-
|
|
945
|
+
const retained = states.filter((state) => readSelectedState(state.name)).length;
|
|
946
|
+
emit.text(`Requested stop for ${states.length} tunnel(s); ${killed.size} process signal(s), ${retained} state record(s) retained pending verified exit.\n`);
|
|
707
947
|
return;
|
|
708
948
|
}
|
|
709
949
|
const name = resolveName(opts.name);
|
|
710
950
|
// Bare `down` targets the default instance. If it's not up but named ones
|
|
711
951
|
// are, don't silently no-op; point the operator at them.
|
|
712
|
-
if (name === DEFAULT_INSTANCE && !
|
|
713
|
-
const others =
|
|
952
|
+
if (name === DEFAULT_INSTANCE && !readSelectedState(DEFAULT_INSTANCE)) {
|
|
953
|
+
const others = listSelectedStates();
|
|
714
954
|
if (others.length > 0) {
|
|
715
955
|
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`);
|
|
716
956
|
return;
|
|
717
957
|
}
|
|
718
958
|
}
|
|
719
959
|
downOne(name, killed);
|
|
720
|
-
emit.text(
|
|
721
|
-
? `
|
|
722
|
-
: `
|
|
960
|
+
emit.text(readSelectedState(name)
|
|
961
|
+
? `Tunnel [${name}] state retained pending verified process exit or custody; ${killed.size} signal(s) sent.\n`
|
|
962
|
+
: `Tunnel [${name}] has no remaining recorded processes.\n`);
|
|
723
963
|
}
|
|
724
964
|
/** Poll until `port` has no LISTEN socket (or the deadline passes). */
|
|
725
965
|
async function waitForPortFree(port, timeoutMs) {
|
|
@@ -753,9 +993,25 @@ async function waitForPortBound(port, timeoutMs) {
|
|
|
753
993
|
* edge reconnects without noticing.
|
|
754
994
|
*/
|
|
755
995
|
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) {
|
|
756
1007
|
const bin = resolveBinName();
|
|
757
1008
|
const name = state.name;
|
|
758
1009
|
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
|
+
};
|
|
759
1015
|
if (!providerIsAlive(state)) {
|
|
760
1016
|
return {
|
|
761
1017
|
ok: false,
|
|
@@ -778,27 +1034,51 @@ export async function reloadOne(state) {
|
|
|
778
1034
|
`\`${bin} tunnel up --name ${name} --allow-path <prefix>\`.`,
|
|
779
1035
|
};
|
|
780
1036
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1037
|
+
const registration = tunnelRegistration(state);
|
|
1038
|
+
if (!registration.known || !registration.record)
|
|
1039
|
+
return { ok: false, message: `[${name}] registration custody changed; retained the tunnel.` };
|
|
1040
|
+
const stopGate = async () => {
|
|
1041
|
+
if (state.provider === "cloudflare" &&
|
|
1042
|
+
state.cloudflared_pid &&
|
|
1043
|
+
verifyProcessGeneration(state.cloudflared_pid, state.cloudflared_start_token) !== "same")
|
|
1044
|
+
return {
|
|
1045
|
+
ok: false,
|
|
1046
|
+
message: `[${name}] provider generation is unknown or changed; retained the tunnel.`,
|
|
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
|
+
};
|
|
792
1060
|
}
|
|
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);
|
|
793
1071
|
}
|
|
794
|
-
|
|
795
|
-
if (!(await waitForPortFree(gatePort, 5_000))) {
|
|
1072
|
+
catch {
|
|
796
1073
|
return {
|
|
797
1074
|
ok: false,
|
|
798
|
-
message: `[${name}]
|
|
799
|
-
`Tunnel is down; check \`${bin} tunnel status\`.`,
|
|
1075
|
+
message: `[${name}] registration custody is unavailable; retained the tunnel.`,
|
|
800
1076
|
};
|
|
801
1077
|
}
|
|
1078
|
+
if (!released.ok)
|
|
1079
|
+
return released;
|
|
1080
|
+
if (registration.record)
|
|
1081
|
+
unregisterServer(registration.record, { coordRoot: registryRoot() });
|
|
802
1082
|
const cfg = readConfig();
|
|
803
1083
|
const gateProc = spawnGate({
|
|
804
1084
|
name,
|
|
@@ -810,6 +1090,7 @@ export async function reloadOne(state) {
|
|
|
810
1090
|
allowPaths: state.allow_paths,
|
|
811
1091
|
gateLogPath: tunnelLogDestinations(name, state.provider).gate,
|
|
812
1092
|
}, launch);
|
|
1093
|
+
const gateStartToken = processStartToken(gateProc.pid);
|
|
813
1094
|
await sleep(800);
|
|
814
1095
|
const pid = gateProc.pid;
|
|
815
1096
|
if (typeof pid !== "number" ||
|
|
@@ -821,15 +1102,33 @@ export async function reloadOne(state) {
|
|
|
821
1102
|
`Tunnel is down; check \`${bin} tunnel logs --name ${name}\`.`,
|
|
822
1103
|
};
|
|
823
1104
|
}
|
|
824
|
-
|
|
825
|
-
|
|
1105
|
+
const next = {
|
|
1106
|
+
...state,
|
|
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
|
+
}
|
|
826
1125
|
const allowNote = state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
|
|
827
1126
|
return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
|
|
828
1127
|
}
|
|
829
1128
|
async function reload(opts) {
|
|
830
1129
|
const bin = resolveBinName();
|
|
831
1130
|
if (opts.all) {
|
|
832
|
-
const all =
|
|
1131
|
+
const all = listSelectedStates();
|
|
833
1132
|
// Only live tunnels are reloadable, and a box commonly accumulates stale
|
|
834
1133
|
// state files from long-dead runs. Reporting those as failures would make
|
|
835
1134
|
// the common post-`allow add` path exit non-zero for no real reason.
|
|
@@ -855,7 +1154,7 @@ async function reload(opts) {
|
|
|
855
1154
|
return;
|
|
856
1155
|
}
|
|
857
1156
|
const name = resolveName(opts.name);
|
|
858
|
-
const state =
|
|
1157
|
+
const state = readSelectedState(name);
|
|
859
1158
|
if (!state) {
|
|
860
1159
|
emit.error({
|
|
861
1160
|
code: "tunnel_not_found",
|
|
@@ -870,7 +1169,8 @@ async function reload(opts) {
|
|
|
870
1169
|
process.exit(1);
|
|
871
1170
|
}
|
|
872
1171
|
function instanceState(state) {
|
|
873
|
-
|
|
1172
|
+
const readiness = readinessOf(state);
|
|
1173
|
+
return readiness === "ready" ? "up" : readiness === "dead" ? "stale" : "unknown";
|
|
874
1174
|
}
|
|
875
1175
|
function fmtUptime(startedAt) {
|
|
876
1176
|
const secs = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
|
|
@@ -886,9 +1186,9 @@ function fmtUptime(startedAt) {
|
|
|
886
1186
|
function statusDetail(state) {
|
|
887
1187
|
const gateAlive = isProcessAlive(state.gate_pid);
|
|
888
1188
|
const providerAlive = providerIsAlive(state);
|
|
889
|
-
const providerPid = state.cloudflared_pid
|
|
1189
|
+
const providerPid = state.cloudflared_pid;
|
|
890
1190
|
const cfg = readConfig();
|
|
891
|
-
emit.text(`${
|
|
1191
|
+
emit.text(`${instanceState(state)} [${state.name}]\n`);
|
|
892
1192
|
emit.text(` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`);
|
|
893
1193
|
emit.text(` URL: ${state.url}\n`);
|
|
894
1194
|
emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
|
|
@@ -909,7 +1209,7 @@ function statusDetail(state) {
|
|
|
909
1209
|
function status(opts) {
|
|
910
1210
|
// Named → detailed single block.
|
|
911
1211
|
if (opts.name) {
|
|
912
|
-
const state =
|
|
1212
|
+
const state = readSelectedState(resolveName(opts.name));
|
|
913
1213
|
if (!state) {
|
|
914
1214
|
emit.text(`down [${resolveName(opts.name)}]\n`);
|
|
915
1215
|
return;
|
|
@@ -918,7 +1218,7 @@ function status(opts) {
|
|
|
918
1218
|
return;
|
|
919
1219
|
}
|
|
920
1220
|
// No name → table of every instance.
|
|
921
|
-
const states =
|
|
1221
|
+
const states = listSelectedStates();
|
|
922
1222
|
if (states.length === 0) {
|
|
923
1223
|
emit.text("down\n");
|
|
924
1224
|
return;
|
|
@@ -955,7 +1255,7 @@ function status(opts) {
|
|
|
955
1255
|
}
|
|
956
1256
|
function logs(opts) {
|
|
957
1257
|
const name = resolveName(opts.name);
|
|
958
|
-
const state =
|
|
1258
|
+
const state = readSelectedState(name);
|
|
959
1259
|
const provider = state?.provider ?? "cloudflare";
|
|
960
1260
|
const destinations = tunnelLogDestinations(name, provider);
|
|
961
1261
|
const path = opts.provider ? destinations.provider : destinations.gate;
|
|
@@ -989,7 +1289,7 @@ const sameEntryIn = (list, entry) => list.some((e) => canonicalEntry(e) === cano
|
|
|
989
1289
|
* restarts were owed.
|
|
990
1290
|
*/
|
|
991
1291
|
function allowChangedHint() {
|
|
992
|
-
const live =
|
|
1292
|
+
const live = listSelectedStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
|
|
993
1293
|
if (live.length === 0)
|
|
994
1294
|
return;
|
|
995
1295
|
emit.text(`Each gate reads the allowlist once at start, so this applies on reload ` +
|
|
@@ -1096,7 +1396,7 @@ export async function refreshCurrentAddress(options) {
|
|
|
1096
1396
|
}
|
|
1097
1397
|
if (!plan.changed || !options.reload)
|
|
1098
1398
|
return result;
|
|
1099
|
-
const live =
|
|
1399
|
+
const live = listSelectedStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
|
|
1100
1400
|
for (const s of live) {
|
|
1101
1401
|
const r = await reloadOne(s);
|
|
1102
1402
|
emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
|