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/src/commands/tunnel.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { type ChildProcess, 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 type { Command } from "commander";
|
|
7
5
|
import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
@@ -9,22 +7,11 @@ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
|
9
7
|
import { resolveCoordRoot } from "../core/agents/coord-client.ts";
|
|
10
8
|
import { resolveBinName } from "../core/config.ts";
|
|
11
9
|
import {
|
|
12
|
-
acquireNoClobberLease,
|
|
13
|
-
createTunnelReadinessVerifier,
|
|
14
|
-
pidAlive,
|
|
15
|
-
processStartToken,
|
|
16
|
-
readServer,
|
|
17
|
-
type ServerRecord,
|
|
18
|
-
serversDir,
|
|
19
|
-
signalIfSameGeneration,
|
|
20
10
|
tryRegisterServer,
|
|
21
11
|
tunnelServerId,
|
|
22
12
|
tunnelServerInput,
|
|
23
13
|
unregisterServer,
|
|
24
|
-
verifyProcessGeneration,
|
|
25
|
-
withServerRegistrationCustody,
|
|
26
14
|
} from "../core/servers/index.ts";
|
|
27
|
-
import { writePrivateJsonAtomic } from "../core/storage/atomic-json.ts";
|
|
28
15
|
import {
|
|
29
16
|
processLogDestination,
|
|
30
17
|
runProcessLogWorker,
|
|
@@ -38,20 +25,26 @@ import {
|
|
|
38
25
|
planCurrentAddresses,
|
|
39
26
|
} from "../lib/tunnel/current-address.ts";
|
|
40
27
|
import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.ts";
|
|
41
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
connectProbe,
|
|
30
|
+
findPidsByCommandLine,
|
|
31
|
+
listeningPorts,
|
|
32
|
+
portListening,
|
|
33
|
+
} from "../lib/tunnel/processes.ts";
|
|
42
34
|
import {
|
|
43
35
|
clearState,
|
|
44
36
|
DEFAULT_INSTANCE,
|
|
45
37
|
ensureCloudflared,
|
|
46
38
|
gateLogFile,
|
|
47
39
|
isProcessAlive,
|
|
40
|
+
isTunnelStateLive,
|
|
41
|
+
listStates,
|
|
48
42
|
providerLogFile,
|
|
49
43
|
readConfig,
|
|
44
|
+
readState,
|
|
50
45
|
type TailscaleMode,
|
|
51
46
|
type TunnelProvider,
|
|
52
47
|
type TunnelState,
|
|
53
|
-
tunnelDir,
|
|
54
|
-
tunnelReadiness,
|
|
55
48
|
writeConfig,
|
|
56
49
|
writeState,
|
|
57
50
|
} from "../lib/tunnel/state.ts";
|
|
@@ -342,6 +335,53 @@ function sleep(ms: number): Promise<void> {
|
|
|
342
335
|
return new Promise((r) => setTimeout(r, ms));
|
|
343
336
|
}
|
|
344
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Kill every process whose command line matches `pattern` (an extended regular
|
|
340
|
+
* expression), skipping our own PID and any already-killed. Returns the count
|
|
341
|
+
* killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
|
|
342
|
+
* even when the state file was lost (which otherwise left them squatting on the
|
|
343
|
+
* port).
|
|
344
|
+
*/
|
|
345
|
+
function killByPattern(pattern: string, alreadyKilled: Set<number>): number {
|
|
346
|
+
let killed = 0;
|
|
347
|
+
for (const pid of findPidsByCommandLine(pattern)) {
|
|
348
|
+
if (!pid || pid === process.pid || alreadyKilled.has(pid)) continue;
|
|
349
|
+
try {
|
|
350
|
+
process.kill(pid);
|
|
351
|
+
alreadyKilled.add(pid);
|
|
352
|
+
killed++;
|
|
353
|
+
} catch {
|
|
354
|
+
/* race: already gone */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return killed;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Command-line pattern for ONE instance's gate, identified by its port. Matches
|
|
362
|
+
* both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
|
|
363
|
+
* re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
|
|
364
|
+
* guarded with `( |$)` so port 9001 doesn't match 90011.
|
|
365
|
+
*/
|
|
366
|
+
function gatePattern(gatePort: number): string {
|
|
367
|
+
return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Sweep stray gate + cloudflared processes for ONE instance, identified by its
|
|
372
|
+
* gate port. Both signatures are port-scoped so tearing down one tunnel never
|
|
373
|
+
* touches another:
|
|
374
|
+
* - gate: see gatePattern (the port is on the gate's argv)
|
|
375
|
+
* - cloudflared: `--url http://localhost:<port>` (order-independent, so it
|
|
376
|
+
* matches regardless of the `--protocol http2` flag we also pass).
|
|
377
|
+
*/
|
|
378
|
+
function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
|
|
379
|
+
return (
|
|
380
|
+
killByPattern(gatePattern(gatePort), alreadyKilled) +
|
|
381
|
+
killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled)
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
345
385
|
/**
|
|
346
386
|
* Pick a gate port for a new instance. An explicit `--gate-port` is honored
|
|
347
387
|
* (and rejected if it's already taken); otherwise scan upward from 9001 for the
|
|
@@ -349,7 +389,7 @@ function sleep(ms: number): Promise<void> {
|
|
|
349
389
|
*/
|
|
350
390
|
async function allocateGatePort(preferred: number | undefined): Promise<number> {
|
|
351
391
|
const used = new Set<number>(
|
|
352
|
-
|
|
392
|
+
listStates()
|
|
353
393
|
.filter((s) => isProcessAlive(s.gate_pid))
|
|
354
394
|
.map((s) => s.gate_port),
|
|
355
395
|
);
|
|
@@ -482,14 +522,13 @@ function runTailscaleShare(
|
|
|
482
522
|
}
|
|
483
523
|
|
|
484
524
|
function stopTailscaleShare(state: TunnelState): boolean {
|
|
485
|
-
if (state.provider !== "tailscale") return true;
|
|
486
|
-
|
|
487
|
-
const cmd = tailscaleCommand(state.tailscale_mode!);
|
|
525
|
+
if (state.provider !== "tailscale" || !state.tailscale_mode) return true;
|
|
526
|
+
const cmd = tailscaleCommand(state.tailscale_mode);
|
|
488
527
|
const logPath = tunnelLogDestinations(state.name, "tailscale").provider;
|
|
489
528
|
const args = [
|
|
490
529
|
cmd,
|
|
491
|
-
`--https=${state.tailscale_https_port}`,
|
|
492
|
-
`--set-path=${state.tailscale_path}`,
|
|
530
|
+
`--https=${state.tailscale_https_port ?? 443}`,
|
|
531
|
+
`--set-path=${state.tailscale_path ?? "/"}`,
|
|
493
532
|
"off",
|
|
494
533
|
];
|
|
495
534
|
const status = runRotatingProcessSync({
|
|
@@ -502,9 +541,9 @@ function stopTailscaleShare(state: TunnelState): boolean {
|
|
|
502
541
|
// surviving serve/funnel mapping could later re-expose whatever next binds
|
|
503
542
|
// that port. Surface it loudly so the operator can clear it by hand.
|
|
504
543
|
emit.text(
|
|
505
|
-
`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path}).\n` +
|
|
544
|
+
`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path ?? "/"}).\n` +
|
|
506
545
|
` It may still be exposing this machine. Clear it with:\n` +
|
|
507
|
-
` tailscale ${cmd} --https=${state.tailscale_https_port} --set-path=${state.tailscale_path} off\n` +
|
|
546
|
+
` tailscale ${cmd} --https=${state.tailscale_https_port ?? 443} --set-path=${state.tailscale_path ?? "/"} off\n` +
|
|
508
547
|
` Log: ${logPath}\n\n`,
|
|
509
548
|
);
|
|
510
549
|
return false;
|
|
@@ -520,48 +559,13 @@ function contextVhost(): string | null {
|
|
|
520
559
|
}
|
|
521
560
|
|
|
522
561
|
function providerIsAlive(state: TunnelState): boolean {
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
const pid = state.cloudflared_pid;
|
|
562
|
+
if (state.provider === "tailscale") return true;
|
|
563
|
+
const pid = state.cloudflared_pid ?? state.provider_pid;
|
|
526
564
|
return typeof pid === "number" && isProcessAlive(pid);
|
|
527
565
|
}
|
|
528
566
|
|
|
529
|
-
function
|
|
530
|
-
return (
|
|
531
|
-
!Object.hasOwn(state, "provider_pid") &&
|
|
532
|
-
!Object.hasOwn(state, "provider_start_token") &&
|
|
533
|
-
(state.provider !== "tailscale" ||
|
|
534
|
-
(state.cloudflared_pid === undefined && state.cloudflared_start_token === undefined))
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function originalProcessWitnesses(state: TunnelState): boolean {
|
|
539
|
-
if (
|
|
540
|
-
!canonicalProviderFields(state) ||
|
|
541
|
-
!Number.isSafeInteger(state.gate_pid) ||
|
|
542
|
-
state.gate_pid < 1 ||
|
|
543
|
-
typeof state.gate_start_token !== "string" ||
|
|
544
|
-
!state.gate_start_token ||
|
|
545
|
-
typeof state.registration_id !== "string" ||
|
|
546
|
-
!state.registration_id
|
|
547
|
-
)
|
|
548
|
-
return false;
|
|
549
|
-
if (state.provider === "tailscale")
|
|
550
|
-
return (
|
|
551
|
-
originalTailscaleRoute(state) &&
|
|
552
|
-
(state.provider_state === "ready" || state.provider_state === "pending")
|
|
553
|
-
);
|
|
554
|
-
return (
|
|
555
|
-
state.provider === "cloudflare" &&
|
|
556
|
-
Number.isSafeInteger(state.cloudflared_pid) &&
|
|
557
|
-
state.cloudflared_pid! > 0 &&
|
|
558
|
-
typeof state.cloudflared_start_token === "string" &&
|
|
559
|
-
!!state.cloudflared_start_token
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
function readinessOf(state: TunnelState) {
|
|
564
|
-
return tunnelReadiness(state, createTunnelReadinessVerifier({ coordRoot: registryRoot() }));
|
|
567
|
+
function tunnelIsAlive(state: TunnelState): boolean {
|
|
568
|
+
return isTunnelStateLive(state);
|
|
565
569
|
}
|
|
566
570
|
|
|
567
571
|
interface GateSpawnOpts {
|
|
@@ -584,7 +588,8 @@ interface GateSpawnOpts {
|
|
|
584
588
|
* re-reads — that snapshot is precisely why `reload` has to exist.
|
|
585
589
|
*/
|
|
586
590
|
function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
|
|
587
|
-
//
|
|
591
|
+
// `--name`/`--port` on argv mirror the env vars; they're what makes the gate
|
|
592
|
+
// process distinguishable per-instance in a command-line search (see sweepStrays).
|
|
588
593
|
const gateProc = spawnRotatingProcess({
|
|
589
594
|
path: o.gateLogPath,
|
|
590
595
|
command: launch.command,
|
|
@@ -605,115 +610,7 @@ function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
|
|
|
605
610
|
return gateProc;
|
|
606
611
|
}
|
|
607
612
|
|
|
608
|
-
export interface TunnelRoute {
|
|
609
|
-
path: string;
|
|
610
|
-
httpsPort: number;
|
|
611
|
-
}
|
|
612
|
-
function originalTailscaleRoute(state: TunnelState): boolean {
|
|
613
|
-
return (
|
|
614
|
-
(state.tailscale_mode === "serve" || state.tailscale_mode === "funnel") &&
|
|
615
|
-
typeof state.tailscale_path === "string" &&
|
|
616
|
-
state.tailscale_path.startsWith("/") &&
|
|
617
|
-
Number.isSafeInteger(state.tailscale_https_port) &&
|
|
618
|
-
state.tailscale_https_port! > 0 &&
|
|
619
|
-
state.tailscale_https_port! <= 65535
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
function routeOf(state: TunnelState | null): TunnelRoute | null {
|
|
623
|
-
return state?.provider === "tailscale" && originalTailscaleRoute(state)
|
|
624
|
-
? { path: state.tailscale_path!, httpsPort: state.tailscale_https_port! }
|
|
625
|
-
: null;
|
|
626
|
-
}
|
|
627
|
-
function routeKey(route: TunnelRoute): string {
|
|
628
|
-
return `${route.httpsPort}:${route.path}`;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/** Serialize the instance and every shared provider route before any external effect. */
|
|
632
|
-
export function withTunnelLifecycleCustody<T>(
|
|
633
|
-
name: string,
|
|
634
|
-
requested: TunnelRoute | null,
|
|
635
|
-
effect: () => PromiseLike<T>,
|
|
636
|
-
): Promise<T>;
|
|
637
|
-
export function withTunnelLifecycleCustody<T>(
|
|
638
|
-
name: string,
|
|
639
|
-
requested: TunnelRoute | null,
|
|
640
|
-
effect: () => T,
|
|
641
|
-
): T;
|
|
642
|
-
export function withTunnelLifecycleCustody<T>(
|
|
643
|
-
name: string,
|
|
644
|
-
requested: TunnelRoute | null,
|
|
645
|
-
effect: () => T | PromiseLike<T>,
|
|
646
|
-
): T | Promise<T> {
|
|
647
|
-
if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) throw new Error("invalid tunnel lifecycle name");
|
|
648
|
-
const leases: Array<ReturnType<typeof acquireNoClobberLease>> = [];
|
|
649
|
-
const release = () => {
|
|
650
|
-
for (const lease of leases.splice(0).reverse()) lease.release();
|
|
651
|
-
};
|
|
652
|
-
const acquire = (category: string, key: string) => {
|
|
653
|
-
const scope = `tunnel-${category}-${createHash("sha256").update(key).digest("hex")}`;
|
|
654
|
-
leases.push(
|
|
655
|
-
acquireNoClobberLease({
|
|
656
|
-
path: resolve(
|
|
657
|
-
tunnelDir(),
|
|
658
|
-
".custody",
|
|
659
|
-
category,
|
|
660
|
-
createHash("sha256").update(key).digest("hex"),
|
|
661
|
-
),
|
|
662
|
-
scope,
|
|
663
|
-
authoritySha256: createHash("sha256").update(scope).digest("hex"),
|
|
664
|
-
staleAfterMs: 1,
|
|
665
|
-
}),
|
|
666
|
-
);
|
|
667
|
-
};
|
|
668
|
-
try {
|
|
669
|
-
acquire("instance", name.toLowerCase());
|
|
670
|
-
const retained = listSelectedStates();
|
|
671
|
-
const existing = routeOf(retained.find((state) => state.name === name) ?? null);
|
|
672
|
-
const routes = [
|
|
673
|
-
...new Set(
|
|
674
|
-
[existing, requested].filter((route): route is TunnelRoute => !!route).map(routeKey),
|
|
675
|
-
),
|
|
676
|
-
].sort();
|
|
677
|
-
for (const key of routes) acquire("route", key);
|
|
678
|
-
for (const other of retained) {
|
|
679
|
-
if (other.name.toLowerCase() === name.toLowerCase()) {
|
|
680
|
-
if (other.name !== name)
|
|
681
|
-
throw new Error(
|
|
682
|
-
`tunnel instance ${name} aliases retained spelling ${other.name}; lifecycle custody is unavailable`,
|
|
683
|
-
);
|
|
684
|
-
continue;
|
|
685
|
-
}
|
|
686
|
-
const route = routeOf(other);
|
|
687
|
-
if (route && routes.includes(routeKey(route)))
|
|
688
|
-
throw new Error(
|
|
689
|
-
`Tailscale route ${routeKey(route)} is retained by tunnel ${other.name}; provider custody is unavailable`,
|
|
690
|
-
);
|
|
691
|
-
}
|
|
692
|
-
const result = effect();
|
|
693
|
-
if (
|
|
694
|
-
result !== null &&
|
|
695
|
-
(typeof result === "object" || typeof result === "function") &&
|
|
696
|
-
typeof (result as PromiseLike<T>).then === "function"
|
|
697
|
-
)
|
|
698
|
-
return Promise.resolve(result).finally(release);
|
|
699
|
-
release();
|
|
700
|
-
return result as T;
|
|
701
|
-
} catch (error) {
|
|
702
|
-
release();
|
|
703
|
-
throw error;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
613
|
async function up(opts: UpOpts): Promise<void> {
|
|
708
|
-
const name = resolveName(opts.name);
|
|
709
|
-
const requested =
|
|
710
|
-
resolveProvider(opts.provider) === "tailscale"
|
|
711
|
-
? { path: resolveTailscalePath(opts.path, name), httpsPort: resolveHttpsPort(opts.httpsPort) }
|
|
712
|
-
: null;
|
|
713
|
-
return withTunnelLifecycleCustody(name, requested, () => upWithCustody(opts));
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
717
614
|
const launch = currentGateLaunch();
|
|
718
615
|
if (!launch) {
|
|
719
616
|
emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
|
|
@@ -733,17 +630,8 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
|
733
630
|
provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
|
|
734
631
|
const allowPaths = resolveAllowPaths(opts.allowPath);
|
|
735
632
|
|
|
736
|
-
const existing =
|
|
737
|
-
|
|
738
|
-
if (readiness === "unknown") {
|
|
739
|
-
emit.error({
|
|
740
|
-
code: "tunnel_custody_unknown",
|
|
741
|
-
message: `Tunnel [${name}] readiness is unknown; retained its state and refused cleanup or replacement.`,
|
|
742
|
-
});
|
|
743
|
-
process.exit(1);
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
if (existing && readiness === "ready") {
|
|
633
|
+
const existing = readState(name);
|
|
634
|
+
if (existing && tunnelIsAlive(existing)) {
|
|
747
635
|
if (existing.provider !== provider) {
|
|
748
636
|
emit.error({
|
|
749
637
|
code: "tunnel_instance_in_use",
|
|
@@ -766,21 +654,16 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
|
766
654
|
emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
|
|
767
655
|
return;
|
|
768
656
|
}
|
|
769
|
-
if (existing)
|
|
770
|
-
downOneWithCustody(name, new Set());
|
|
771
|
-
if (readSelectedState(name)) {
|
|
772
|
-
emit.error({
|
|
773
|
-
code: "tunnel_custody_unknown",
|
|
774
|
-
message: `Tunnel [${name}] has a live or unverifiable process; retained its state and refused a replacement.`,
|
|
775
|
-
});
|
|
776
|
-
process.exit(1);
|
|
777
|
-
}
|
|
778
|
-
}
|
|
657
|
+
if (existing) downOne(name, new Set());
|
|
779
658
|
|
|
780
659
|
// Allocate the gate port (after clearing dead state so its old port frees up
|
|
781
660
|
// for reuse). Explicit --gate-port is validated; otherwise auto-scan.
|
|
782
661
|
const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
|
|
783
662
|
|
|
663
|
+
// Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
|
|
664
|
+
// a prior crashed or state-cleared run so the gate port is free before we bind.
|
|
665
|
+
if (sweepStrays(gatePort, new Set())) await sleep(500);
|
|
666
|
+
|
|
784
667
|
const cfg = readConfig();
|
|
785
668
|
if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
|
|
786
669
|
emit.error({
|
|
@@ -828,13 +711,17 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
|
828
711
|
launch,
|
|
829
712
|
);
|
|
830
713
|
|
|
831
|
-
const gateStartToken = processStartToken(gateProc.pid!);
|
|
832
714
|
await sleep(800);
|
|
833
715
|
|
|
834
716
|
// A host CLI that re-executes itself takes longer to load than a bare script,
|
|
835
717
|
// so wait for the gate to actually listen before the provider forwards to it.
|
|
836
718
|
if (!isProcessAlive(gateProc.pid!) || !(await waitForPortBound(gatePort, 15_000))) {
|
|
837
|
-
|
|
719
|
+
try {
|
|
720
|
+
process.kill(gateProc.pid!);
|
|
721
|
+
} catch {
|
|
722
|
+
/* already dead */
|
|
723
|
+
}
|
|
724
|
+
sweepStrays(gatePort, new Set());
|
|
838
725
|
emit.error({
|
|
839
726
|
code: "tunnel_gate_failed",
|
|
840
727
|
message: `Gate failed to start. Check log: ${gateLogPath}`,
|
|
@@ -845,8 +732,6 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
|
845
732
|
let url: string;
|
|
846
733
|
let registered = true;
|
|
847
734
|
let cloudflaredPid: number | undefined;
|
|
848
|
-
let cloudflaredStartToken: string | undefined;
|
|
849
|
-
let pendingReceipt: ServerRecord | null = null;
|
|
850
735
|
|
|
851
736
|
if (provider === "cloudflare") {
|
|
852
737
|
// Force HTTP/2 transport. The default QUIC transport wedges at precheck on
|
|
@@ -862,13 +747,20 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
|
862
747
|
});
|
|
863
748
|
cfdProc.unref();
|
|
864
749
|
cloudflaredPid = cfdProc.pid!;
|
|
865
|
-
cloudflaredStartToken = processStartToken(cloudflaredPid) ?? undefined;
|
|
866
750
|
|
|
867
751
|
const ready = await waitForReady(providerLogPath, 30_000);
|
|
868
752
|
registered = ready.registered;
|
|
869
753
|
if (!ready.url) {
|
|
870
|
-
|
|
871
|
-
|
|
754
|
+
try {
|
|
755
|
+
process.kill(gateProc.pid!);
|
|
756
|
+
} catch {
|
|
757
|
+
/* already dead */
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
process.kill(cloudflaredPid);
|
|
761
|
+
} catch {
|
|
762
|
+
/* already dead */
|
|
763
|
+
}
|
|
872
764
|
emit.error({
|
|
873
765
|
code: "tunnel_url_timeout",
|
|
874
766
|
message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
|
|
@@ -884,38 +776,12 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
|
884
776
|
// `heal` would then be unable to see or clean up.
|
|
885
777
|
url = tailscaleUrl(tailscalePath!, tailscaleHttpsPort!);
|
|
886
778
|
const gateTarget = `http://127.0.0.1:${gatePort}`;
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
started_at: new Date().toISOString(),
|
|
894
|
-
target,
|
|
895
|
-
vhost,
|
|
896
|
-
gate_port: gatePort,
|
|
897
|
-
allow_paths: allowPaths,
|
|
898
|
-
tailscale_mode: tailscaleMode,
|
|
899
|
-
tailscale_path: tailscalePath,
|
|
900
|
-
tailscale_https_port: tailscaleHttpsPort,
|
|
901
|
-
provider_state: "pending",
|
|
902
|
-
};
|
|
903
|
-
pendingReceipt = recordTunnelServer(pending);
|
|
904
|
-
if (!pendingReceipt) {
|
|
905
|
-
const evidence = recordRefusedTunnelLaunch(pending);
|
|
906
|
-
signalIfSameGeneration(pending.gate_pid, pending.gate_start_token, "SIGTERM");
|
|
907
|
-
throw new Error(
|
|
908
|
-
`Tailscale admission refused before provider effect; retained witnesses at ${evidence}`,
|
|
909
|
-
);
|
|
910
|
-
}
|
|
911
|
-
beginTailscaleRoute(pending, pendingReceipt, () =>
|
|
912
|
-
runTailscaleShare(
|
|
913
|
-
tailscaleMode!,
|
|
914
|
-
gateTarget,
|
|
915
|
-
tailscalePath!,
|
|
916
|
-
tailscaleHttpsPort!,
|
|
917
|
-
providerLogPath,
|
|
918
|
-
),
|
|
779
|
+
runTailscaleShare(
|
|
780
|
+
tailscaleMode!,
|
|
781
|
+
gateTarget,
|
|
782
|
+
tailscalePath!,
|
|
783
|
+
tailscaleHttpsPort!,
|
|
784
|
+
providerLogPath,
|
|
919
785
|
);
|
|
920
786
|
}
|
|
921
787
|
|
|
@@ -923,10 +789,7 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
|
923
789
|
name,
|
|
924
790
|
provider,
|
|
925
791
|
url,
|
|
926
|
-
...(provider === "tailscale" ? { provider_state: "ready" as const } : {}),
|
|
927
792
|
gate_pid: gateProc.pid!,
|
|
928
|
-
gate_start_token: gateStartToken ?? undefined,
|
|
929
|
-
cloudflared_start_token: cloudflaredStartToken,
|
|
930
793
|
cloudflared_pid: cloudflaredPid,
|
|
931
794
|
started_at: new Date().toISOString(),
|
|
932
795
|
target,
|
|
@@ -937,22 +800,8 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
|
|
|
937
800
|
tailscale_path: tailscalePath,
|
|
938
801
|
tailscale_https_port: tailscaleHttpsPort,
|
|
939
802
|
};
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
try {
|
|
943
|
-
if (!receipt) throw new Error("registration refused");
|
|
944
|
-
publishTunnelState(state, receipt);
|
|
945
|
-
} catch {
|
|
946
|
-
const evidence = recordRefusedTunnelLaunch(state);
|
|
947
|
-
signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
|
|
948
|
-
if (state.cloudflared_pid)
|
|
949
|
-
signalIfSameGeneration(state.cloudflared_pid, state.cloudflared_start_token, "SIGTERM");
|
|
950
|
-
emit.error({
|
|
951
|
-
code: "tunnel_registration_refused",
|
|
952
|
-
message: `Tunnel [${name}] registration custody was refused; saved process witnesses at ${evidence}.`,
|
|
953
|
-
});
|
|
954
|
-
process.exit(1);
|
|
955
|
-
}
|
|
803
|
+
writeState(state);
|
|
804
|
+
recordTunnelServer(state);
|
|
956
805
|
|
|
957
806
|
const bin = resolveBinName();
|
|
958
807
|
const stopHint =
|
|
@@ -987,191 +836,43 @@ function registryRoot(): string {
|
|
|
987
836
|
return resolveCoordRoot() ?? process.cwd();
|
|
988
837
|
}
|
|
989
838
|
|
|
990
|
-
function
|
|
991
|
-
return Object.assign(
|
|
992
|
-
new Error(
|
|
993
|
-
`Tunnel [${name}] state is unreadable or its custody is unknown; retained the frame.`,
|
|
994
|
-
),
|
|
995
|
-
{
|
|
996
|
-
code: "tunnel_custody_unknown",
|
|
997
|
-
},
|
|
998
|
-
);
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
function readSelectedState(name: string): TunnelState | null {
|
|
1002
|
-
const path = resolve(
|
|
1003
|
-
tunnelDir(),
|
|
1004
|
-
name === DEFAULT_INSTANCE ? "state.json" : `state-${name}.json`,
|
|
1005
|
-
);
|
|
1006
|
-
let bytes: string;
|
|
1007
|
-
try {
|
|
1008
|
-
bytes = readFileSync(path, "utf8");
|
|
1009
|
-
} catch (error) {
|
|
1010
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
1011
|
-
throw unknownTunnelFrame(name);
|
|
1012
|
-
}
|
|
1013
|
-
try {
|
|
1014
|
-
const raw = JSON.parse(bytes) as TunnelState;
|
|
1015
|
-
if (
|
|
1016
|
-
!raw ||
|
|
1017
|
-
typeof raw !== "object" ||
|
|
1018
|
-
Array.isArray(raw) ||
|
|
1019
|
-
raw.name !== name ||
|
|
1020
|
-
(raw.provider !== "cloudflare" && raw.provider !== "tailscale") ||
|
|
1021
|
-
(raw.provider === "tailscale" && !originalTailscaleRoute(raw)) ||
|
|
1022
|
-
typeof raw.url !== "string" ||
|
|
1023
|
-
!raw.url ||
|
|
1024
|
-
typeof raw.target !== "string" ||
|
|
1025
|
-
!raw.target ||
|
|
1026
|
-
typeof raw.vhost !== "string" ||
|
|
1027
|
-
!Number.isSafeInteger(raw.gate_pid) ||
|
|
1028
|
-
raw.gate_pid < 1 ||
|
|
1029
|
-
!Number.isSafeInteger(raw.gate_port) ||
|
|
1030
|
-
raw.gate_port < 1 ||
|
|
1031
|
-
raw.gate_port > 65535 ||
|
|
1032
|
-
!Array.isArray(raw.allow_paths) ||
|
|
1033
|
-
raw.allow_paths.some((path) => typeof path !== "string")
|
|
1034
|
-
)
|
|
1035
|
-
throw unknownTunnelFrame(name);
|
|
1036
|
-
return raw;
|
|
1037
|
-
} catch {
|
|
1038
|
-
throw unknownTunnelFrame(name);
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
function listSelectedStates(): TunnelState[] {
|
|
1043
|
-
let files: string[];
|
|
1044
|
-
try {
|
|
1045
|
-
files = readdirSync(tunnelDir());
|
|
1046
|
-
} catch (error) {
|
|
1047
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
1048
|
-
throw unknownTunnelFrame("inventory");
|
|
1049
|
-
}
|
|
1050
|
-
const states: TunnelState[] = [];
|
|
1051
|
-
for (const file of files) {
|
|
1052
|
-
const name = file === "state.json" ? DEFAULT_INSTANCE : /^state-(.+)\.json$/.exec(file)?.[1];
|
|
1053
|
-
if (!name) continue;
|
|
1054
|
-
const state = readSelectedState(name);
|
|
1055
|
-
if (state) states.push(state);
|
|
1056
|
-
}
|
|
1057
|
-
return states.sort((a, b) =>
|
|
1058
|
-
(typeof b.started_at === "string" ? b.started_at : "").localeCompare(
|
|
1059
|
-
typeof a.started_at === "string" ? a.started_at : "",
|
|
1060
|
-
),
|
|
1061
|
-
);
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
function matchesTunnelReceipt(state: TunnelState, receipt: ServerRecord): boolean {
|
|
1065
|
-
return (
|
|
1066
|
-
originalProcessWitnesses(state) &&
|
|
1067
|
-
tunnelServerId(state.name) === receipt.id &&
|
|
1068
|
-
receipt.host === hostname() &&
|
|
1069
|
-
receipt.kind === "service" &&
|
|
1070
|
-
receipt.type === "tunnel" &&
|
|
1071
|
-
receipt.port === state.gate_port &&
|
|
1072
|
-
receipt.url === state.url &&
|
|
1073
|
-
receipt.registration_id === state.registration_id &&
|
|
1074
|
-
receipt.pid === state.gate_pid &&
|
|
1075
|
-
receipt.start_token === state.gate_start_token
|
|
1076
|
-
);
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
/** Retain a refused launch without overwriting another launch's canonical state. */
|
|
1080
|
-
export function recordRefusedTunnelLaunch(state: TunnelState): string {
|
|
1081
|
-
const path = resolve(tunnelDir(), `failed-${state.name}-${randomUUID()}.json`);
|
|
1082
|
-
writePrivateJsonAtomic(path, state);
|
|
1083
|
-
return path;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/** A canonical state frame may be published only by its current registration. */
|
|
1087
|
-
export function publishTunnelState(state: TunnelState, receipt: ServerRecord): void {
|
|
1088
|
-
if (!matchesTunnelReceipt(state, receipt))
|
|
1089
|
-
throw new Error("tunnel state registration custody does not match");
|
|
1090
|
-
withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, () => writeState(state));
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
/** Retain pending route intent before asking the provider to change external state. */
|
|
1094
|
-
export function beginTailscaleRoute<T>(
|
|
1095
|
-
state: TunnelState,
|
|
1096
|
-
receipt: ServerRecord,
|
|
1097
|
-
effect: () => T,
|
|
1098
|
-
): T {
|
|
1099
|
-
if (state.provider !== "tailscale" || state.provider_state !== "pending")
|
|
1100
|
-
throw new Error("Tailscale route intent is not pending");
|
|
1101
|
-
publishTunnelState(state, receipt);
|
|
1102
|
-
return withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, effect);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function recordTunnelServer(state: TunnelState): ServerRecord | null {
|
|
1106
|
-
if (!state.gate_start_token) return null;
|
|
839
|
+
function recordTunnelServer(state: TunnelState): void {
|
|
1107
840
|
const coordRoot = registryRoot();
|
|
1108
|
-
|
|
1109
|
-
if (record) state.registration_id = record.registration_id;
|
|
1110
|
-
return record;
|
|
841
|
+
tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
|
|
1111
842
|
}
|
|
1112
843
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
try {
|
|
1119
|
-
record = readServer(id, { coordRoot });
|
|
1120
|
-
} catch {
|
|
1121
|
-
return { known: false, record: null };
|
|
1122
|
-
}
|
|
1123
|
-
const present = existsSync(resolve(serversDir(coordRoot), `${id}.json`));
|
|
1124
|
-
if (!present) return { known: false, record: null };
|
|
1125
|
-
return { known: !!record && matchesTunnelReceipt(state, record), record };
|
|
844
|
+
/** Tear down a single instance by name. Returns the number of processes killed. */
|
|
845
|
+
function portHeldByOtherLiveInstance(port: number, name: string): boolean {
|
|
846
|
+
return listStates().some(
|
|
847
|
+
(s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid),
|
|
848
|
+
);
|
|
1126
849
|
}
|
|
1127
850
|
|
|
1128
|
-
/** Unknown saved identities retain state and never authorize a signal. */
|
|
1129
851
|
export function downOne(name: string, killed: Set<number>): number {
|
|
1130
|
-
try {
|
|
1131
|
-
return withTunnelLifecycleCustody(name, null, () => downOneWithCustody(name, killed));
|
|
1132
|
-
} catch {
|
|
1133
|
-
return 0;
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
function downOneWithCustody(name: string, killed: Set<number>): number {
|
|
1138
852
|
const before = killed.size;
|
|
1139
|
-
const state =
|
|
1140
|
-
if (
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
if (pidAlive(target.pid) && verifyProcessGeneration(target.pid, target.token) !== "same")
|
|
1152
|
-
return 0;
|
|
1153
|
-
}
|
|
1154
|
-
if (!registration.record) return 0;
|
|
1155
|
-
let cleared = false;
|
|
1156
|
-
try {
|
|
1157
|
-
withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, () => {
|
|
1158
|
-
for (const target of targets) {
|
|
1159
|
-
if (!pidAlive(target.pid) || killed.has(target.pid)) continue;
|
|
1160
|
-
const result = signalIfSameGeneration(target.pid, target.token, "SIGTERM");
|
|
1161
|
-
if (!result.sent) throw new Error("tunnel process custody is unknown");
|
|
1162
|
-
killed.add(target.pid);
|
|
853
|
+
const state = readState(name);
|
|
854
|
+
if (state) {
|
|
855
|
+
if (state.provider === "tailscale") stopTailscaleShare(state);
|
|
856
|
+
for (const pid of [state.gate_pid, state.cloudflared_pid, state.provider_pid]) {
|
|
857
|
+
if (typeof pid !== "number") continue;
|
|
858
|
+
if (isProcessAlive(pid)) {
|
|
859
|
+
try {
|
|
860
|
+
process.kill(pid);
|
|
861
|
+
killed.add(pid);
|
|
862
|
+
} catch {
|
|
863
|
+
/* race: already gone */
|
|
864
|
+
}
|
|
1163
865
|
}
|
|
1164
|
-
|
|
1165
|
-
if (targets.some((target) => pidAlive(target.pid))) return;
|
|
1166
|
-
if (JSON.stringify(readSelectedState(name)) !== JSON.stringify(state)) return;
|
|
1167
|
-
if (state.provider === "tailscale" && !stopTailscaleShare(state)) return;
|
|
1168
|
-
clearState(name);
|
|
1169
|
-
cleared = true;
|
|
1170
|
-
});
|
|
1171
|
-
} catch {
|
|
1172
|
-
return killed.size - before;
|
|
866
|
+
}
|
|
1173
867
|
}
|
|
1174
|
-
|
|
868
|
+
// Fallback: sweep orphans on this instance's gate port, even when state was
|
|
869
|
+
// lost; they'd otherwise squat on the port and break the next `up`. Skip it
|
|
870
|
+
// when another live instance now owns that port: a stale record's port is
|
|
871
|
+
// reused after a reboot, and sweeping it would kill the live tunnel there.
|
|
872
|
+
const port = state?.gate_port ?? DEFAULT_GATE_PORT;
|
|
873
|
+
if (!portHeldByOtherLiveInstance(port, name)) sweepStrays(port, killed);
|
|
874
|
+
clearState(name);
|
|
875
|
+
unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
|
|
1175
876
|
return killed.size - before;
|
|
1176
877
|
}
|
|
1177
878
|
|
|
@@ -1179,15 +880,14 @@ function down(opts: DownOpts): void {
|
|
|
1179
880
|
const killed = new Set<number>();
|
|
1180
881
|
|
|
1181
882
|
if (opts.all) {
|
|
1182
|
-
const states =
|
|
883
|
+
const states = listStates();
|
|
1183
884
|
if (states.length === 0) {
|
|
1184
885
|
emit.text("No tunnels up. Nothing to stop.\n");
|
|
1185
886
|
return;
|
|
1186
887
|
}
|
|
1187
888
|
for (const s of states) downOne(s.name, killed);
|
|
1188
|
-
const retained = states.filter((state) => readSelectedState(state.name)).length;
|
|
1189
889
|
emit.text(
|
|
1190
|
-
`
|
|
890
|
+
`Stopped ${states.length} tunnel(s) [${states.map((s) => s.name).join(", ")}], ${killed.size} process(es).\n`,
|
|
1191
891
|
);
|
|
1192
892
|
return;
|
|
1193
893
|
}
|
|
@@ -1195,8 +895,8 @@ function down(opts: DownOpts): void {
|
|
|
1195
895
|
const name = resolveName(opts.name);
|
|
1196
896
|
// Bare `down` targets the default instance. If it's not up but named ones
|
|
1197
897
|
// are, don't silently no-op; point the operator at them.
|
|
1198
|
-
if (name === DEFAULT_INSTANCE && !
|
|
1199
|
-
const others =
|
|
898
|
+
if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
|
|
899
|
+
const others = listStates();
|
|
1200
900
|
if (others.length > 0) {
|
|
1201
901
|
emit.text(
|
|
1202
902
|
`No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`${resolveBinName()} tunnel down --name <name>\` or \`${resolveBinName()} tunnel down --all\`.\n`,
|
|
@@ -1207,9 +907,9 @@ function down(opts: DownOpts): void {
|
|
|
1207
907
|
|
|
1208
908
|
downOne(name, killed);
|
|
1209
909
|
emit.text(
|
|
1210
|
-
|
|
1211
|
-
? `
|
|
1212
|
-
: `Tunnel [${name}]
|
|
910
|
+
killed.size === 0
|
|
911
|
+
? `No tunnel processes found for [${name}]. Nothing to stop.\n`
|
|
912
|
+
: `Stopped ${killed.size} process(es). Tunnel [${name}] down.\n`,
|
|
1213
913
|
);
|
|
1214
914
|
}
|
|
1215
915
|
|
|
@@ -1245,29 +945,10 @@ async function waitForPortBound(port: number, timeoutMs: number): Promise<boolea
|
|
|
1245
945
|
* edge reconnects without noticing.
|
|
1246
946
|
*/
|
|
1247
947
|
export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; message: string }> {
|
|
1248
|
-
try {
|
|
1249
|
-
return await withTunnelLifecycleCustody(state.name, routeOf(state), () =>
|
|
1250
|
-
reloadOneWithCustody(state),
|
|
1251
|
-
);
|
|
1252
|
-
} catch {
|
|
1253
|
-
return {
|
|
1254
|
-
ok: false,
|
|
1255
|
-
message: `[${state.name}] lifecycle or provider route custody is unavailable; retained the tunnel.`,
|
|
1256
|
-
};
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean; message: string }> {
|
|
1261
948
|
const bin = resolveBinName();
|
|
1262
949
|
const name = state.name;
|
|
1263
950
|
const gatePort = state.gate_port;
|
|
1264
951
|
|
|
1265
|
-
if (!canonicalProviderFields(state))
|
|
1266
|
-
return {
|
|
1267
|
-
ok: false,
|
|
1268
|
-
message: `[${name}] obsolete or ambiguous provider authority is unknown; retained the tunnel.`,
|
|
1269
|
-
};
|
|
1270
|
-
|
|
1271
952
|
if (!providerIsAlive(state)) {
|
|
1272
953
|
return {
|
|
1273
954
|
ok: false,
|
|
@@ -1293,54 +974,28 @@ async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean;
|
|
|
1293
974
|
};
|
|
1294
975
|
}
|
|
1295
976
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
state.
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
ok: false,
|
|
1307
|
-
message: `[${name}] provider generation is unknown or changed; retained the tunnel.`,
|
|
1308
|
-
};
|
|
1309
|
-
if (pidAlive(state.gate_pid)) {
|
|
1310
|
-
if (!registration.record)
|
|
1311
|
-
return {
|
|
1312
|
-
ok: false,
|
|
1313
|
-
message: `[${name}] registration custody is unavailable; retained the tunnel.`,
|
|
1314
|
-
};
|
|
1315
|
-
const result = signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
|
|
1316
|
-
if (!result.sent)
|
|
1317
|
-
return {
|
|
1318
|
-
ok: false,
|
|
1319
|
-
message: `[${name}] gate generation is unknown or changed; retained the tunnel.`,
|
|
1320
|
-
};
|
|
977
|
+
// Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
|
|
978
|
+
// matches `--url http://localhost:<port>`, which is the provider process we
|
|
979
|
+
// are going out of our way to keep alive.
|
|
980
|
+
const killed = new Set<number>();
|
|
981
|
+
if (isProcessAlive(state.gate_pid)) {
|
|
982
|
+
try {
|
|
983
|
+
process.kill(state.gate_pid);
|
|
984
|
+
killed.add(state.gate_pid);
|
|
985
|
+
} catch {
|
|
986
|
+
/* race: already gone */
|
|
1321
987
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
};
|
|
1327
|
-
return { ok: true, message: "gate released" };
|
|
1328
|
-
};
|
|
1329
|
-
let released: { ok: boolean; message: string };
|
|
1330
|
-
try {
|
|
1331
|
-
released = await withServerRegistrationCustody(
|
|
1332
|
-
registration.record,
|
|
1333
|
-
{ coordRoot: registryRoot() },
|
|
1334
|
-
stopGate,
|
|
1335
|
-
);
|
|
1336
|
-
} catch {
|
|
988
|
+
}
|
|
989
|
+
killByPattern(gatePattern(gatePort), killed);
|
|
990
|
+
|
|
991
|
+
if (!(await waitForPortFree(gatePort, 5_000))) {
|
|
1337
992
|
return {
|
|
1338
993
|
ok: false,
|
|
1339
|
-
message:
|
|
994
|
+
message:
|
|
995
|
+
`[${name}] port ${gatePort} never released, so the old gate looks wedged. ` +
|
|
996
|
+
`Tunnel is down; check \`${bin} tunnel status\`.`,
|
|
1340
997
|
};
|
|
1341
998
|
}
|
|
1342
|
-
if (!released.ok) return released;
|
|
1343
|
-
if (registration.record) unregisterServer(registration.record, { coordRoot: registryRoot() });
|
|
1344
999
|
|
|
1345
1000
|
const cfg = readConfig();
|
|
1346
1001
|
const gateProc = spawnGate(
|
|
@@ -1357,7 +1012,6 @@ async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean;
|
|
|
1357
1012
|
launch,
|
|
1358
1013
|
);
|
|
1359
1014
|
|
|
1360
|
-
const gateStartToken = processStartToken(gateProc.pid!);
|
|
1361
1015
|
await sleep(800);
|
|
1362
1016
|
const pid = gateProc.pid;
|
|
1363
1017
|
if (
|
|
@@ -1373,24 +1027,8 @@ async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean;
|
|
|
1373
1027
|
};
|
|
1374
1028
|
}
|
|
1375
1029
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
gate_pid: pid,
|
|
1379
|
-
gate_start_token: gateStartToken ?? undefined,
|
|
1380
|
-
registration_id: undefined,
|
|
1381
|
-
};
|
|
1382
|
-
const nextReceipt = recordTunnelServer(next);
|
|
1383
|
-
try {
|
|
1384
|
-
if (!nextReceipt) throw new Error("registration refused");
|
|
1385
|
-
publishTunnelState(next, nextReceipt);
|
|
1386
|
-
} catch {
|
|
1387
|
-
const evidence = recordRefusedTunnelLaunch(next);
|
|
1388
|
-
signalIfSameGeneration(pid, gateStartToken, "SIGTERM");
|
|
1389
|
-
return {
|
|
1390
|
-
ok: false,
|
|
1391
|
-
message: `[${name}] new gate registration custody was refused; retained witnesses at ${evidence}.`,
|
|
1392
|
-
};
|
|
1393
|
-
}
|
|
1030
|
+
writeState({ ...state, gate_pid: pid });
|
|
1031
|
+
recordTunnelServer({ ...state, gate_pid: pid });
|
|
1394
1032
|
const allowNote =
|
|
1395
1033
|
state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
|
|
1396
1034
|
return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
|
|
@@ -1400,7 +1038,7 @@ async function reload(opts: ReloadOpts): Promise<void> {
|
|
|
1400
1038
|
const bin = resolveBinName();
|
|
1401
1039
|
|
|
1402
1040
|
if (opts.all) {
|
|
1403
|
-
const all =
|
|
1041
|
+
const all = listStates();
|
|
1404
1042
|
// Only live tunnels are reloadable, and a box commonly accumulates stale
|
|
1405
1043
|
// state files from long-dead runs. Reporting those as failures would make
|
|
1406
1044
|
// the common post-`allow add` path exit non-zero for no real reason.
|
|
@@ -1429,7 +1067,7 @@ async function reload(opts: ReloadOpts): Promise<void> {
|
|
|
1429
1067
|
}
|
|
1430
1068
|
|
|
1431
1069
|
const name = resolveName(opts.name);
|
|
1432
|
-
const state =
|
|
1070
|
+
const state = readState(name);
|
|
1433
1071
|
if (!state) {
|
|
1434
1072
|
emit.error({
|
|
1435
1073
|
code: "tunnel_not_found",
|
|
@@ -1443,9 +1081,8 @@ async function reload(opts: ReloadOpts): Promise<void> {
|
|
|
1443
1081
|
if (!r.ok) process.exit(1);
|
|
1444
1082
|
}
|
|
1445
1083
|
|
|
1446
|
-
function instanceState(state: TunnelState): "up" | "stale"
|
|
1447
|
-
|
|
1448
|
-
return readiness === "ready" ? "up" : readiness === "dead" ? "stale" : "unknown";
|
|
1084
|
+
function instanceState(state: TunnelState): "up" | "stale" {
|
|
1085
|
+
return tunnelIsAlive(state) ? "up" : "stale";
|
|
1449
1086
|
}
|
|
1450
1087
|
|
|
1451
1088
|
function fmtUptime(startedAt: string): string {
|
|
@@ -1460,9 +1097,9 @@ function fmtUptime(startedAt: string): string {
|
|
|
1460
1097
|
function statusDetail(state: TunnelState): void {
|
|
1461
1098
|
const gateAlive = isProcessAlive(state.gate_pid);
|
|
1462
1099
|
const providerAlive = providerIsAlive(state);
|
|
1463
|
-
const providerPid = state.cloudflared_pid;
|
|
1100
|
+
const providerPid = state.cloudflared_pid ?? state.provider_pid;
|
|
1464
1101
|
const cfg = readConfig();
|
|
1465
|
-
emit.text(`${
|
|
1102
|
+
emit.text(`${gateAlive && providerAlive ? "up" : "stale"} [${state.name}]\n`);
|
|
1466
1103
|
emit.text(
|
|
1467
1104
|
` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`,
|
|
1468
1105
|
);
|
|
@@ -1485,7 +1122,7 @@ function statusDetail(state: TunnelState): void {
|
|
|
1485
1122
|
function status(opts: StatusOpts): void {
|
|
1486
1123
|
// Named → detailed single block.
|
|
1487
1124
|
if (opts.name) {
|
|
1488
|
-
const state =
|
|
1125
|
+
const state = readState(resolveName(opts.name));
|
|
1489
1126
|
if (!state) {
|
|
1490
1127
|
emit.text(`down [${resolveName(opts.name)}]\n`);
|
|
1491
1128
|
return;
|
|
@@ -1495,7 +1132,7 @@ function status(opts: StatusOpts): void {
|
|
|
1495
1132
|
}
|
|
1496
1133
|
|
|
1497
1134
|
// No name → table of every instance.
|
|
1498
|
-
const states =
|
|
1135
|
+
const states = listStates();
|
|
1499
1136
|
if (states.length === 0) {
|
|
1500
1137
|
emit.text("down\n");
|
|
1501
1138
|
return;
|
|
@@ -1538,7 +1175,7 @@ function status(opts: StatusOpts): void {
|
|
|
1538
1175
|
|
|
1539
1176
|
function logs(opts: LogsOpts): void {
|
|
1540
1177
|
const name = resolveName(opts.name);
|
|
1541
|
-
const state =
|
|
1178
|
+
const state = readState(name);
|
|
1542
1179
|
const provider = state?.provider ?? "cloudflare";
|
|
1543
1180
|
const destinations = tunnelLogDestinations(name, provider);
|
|
1544
1181
|
const path = opts.provider ? destinations.provider : destinations.gate;
|
|
@@ -1575,9 +1212,7 @@ const sameEntryIn = (list: readonly string[], entry: string) =>
|
|
|
1575
1212
|
* restarts were owed.
|
|
1576
1213
|
*/
|
|
1577
1214
|
function allowChangedHint(): void {
|
|
1578
|
-
const live =
|
|
1579
|
-
(s) => s.provider === "cloudflare" && providerIsAlive(s),
|
|
1580
|
-
);
|
|
1215
|
+
const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
|
|
1581
1216
|
if (live.length === 0) return;
|
|
1582
1217
|
emit.text(
|
|
1583
1218
|
`Each gate reads the allowlist once at start, so this applies on reload ` +
|
|
@@ -1719,9 +1354,7 @@ export async function refreshCurrentAddress(
|
|
|
1719
1354
|
}
|
|
1720
1355
|
if (!plan.changed || !options.reload) return result;
|
|
1721
1356
|
|
|
1722
|
-
const live =
|
|
1723
|
-
(s) => s.provider === "cloudflare" && providerIsAlive(s),
|
|
1724
|
-
);
|
|
1357
|
+
const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
|
|
1725
1358
|
for (const s of live) {
|
|
1726
1359
|
const r = await reloadOne(s);
|
|
1727
1360
|
emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
|