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/src/commands/tunnel.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ChildProcess, 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 type { Command } from "commander";
|
|
5
7
|
import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
@@ -7,11 +9,22 @@ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
|
7
9
|
import { resolveCoordRoot } from "../core/agents/coord-client.ts";
|
|
8
10
|
import { resolveBinName } from "../core/config.ts";
|
|
9
11
|
import {
|
|
12
|
+
acquireNoClobberLease,
|
|
13
|
+
createTunnelReadinessVerifier,
|
|
14
|
+
pidAlive,
|
|
15
|
+
processStartToken,
|
|
16
|
+
readServer,
|
|
17
|
+
type ServerRecord,
|
|
18
|
+
serversDir,
|
|
19
|
+
signalIfSameGeneration,
|
|
10
20
|
tryRegisterServer,
|
|
11
21
|
tunnelServerId,
|
|
12
22
|
tunnelServerInput,
|
|
13
23
|
unregisterServer,
|
|
24
|
+
verifyProcessGeneration,
|
|
25
|
+
withServerRegistrationCustody,
|
|
14
26
|
} from "../core/servers/index.ts";
|
|
27
|
+
import { writePrivateJsonAtomic } from "../core/storage/atomic-json.ts";
|
|
15
28
|
import {
|
|
16
29
|
processLogDestination,
|
|
17
30
|
runProcessLogWorker,
|
|
@@ -25,26 +38,20 @@ import {
|
|
|
25
38
|
planCurrentAddresses,
|
|
26
39
|
} from "../lib/tunnel/current-address.ts";
|
|
27
40
|
import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.ts";
|
|
28
|
-
import {
|
|
29
|
-
connectProbe,
|
|
30
|
-
findPidsByCommandLine,
|
|
31
|
-
listeningPorts,
|
|
32
|
-
portListening,
|
|
33
|
-
} from "../lib/tunnel/processes.ts";
|
|
41
|
+
import { connectProbe, listeningPorts, portListening } from "../lib/tunnel/processes.ts";
|
|
34
42
|
import {
|
|
35
43
|
clearState,
|
|
36
44
|
DEFAULT_INSTANCE,
|
|
37
45
|
ensureCloudflared,
|
|
38
46
|
gateLogFile,
|
|
39
47
|
isProcessAlive,
|
|
40
|
-
isTunnelStateLive,
|
|
41
|
-
listStates,
|
|
42
48
|
providerLogFile,
|
|
43
49
|
readConfig,
|
|
44
|
-
readState,
|
|
45
50
|
type TailscaleMode,
|
|
46
51
|
type TunnelProvider,
|
|
47
52
|
type TunnelState,
|
|
53
|
+
tunnelDir,
|
|
54
|
+
tunnelReadiness,
|
|
48
55
|
writeConfig,
|
|
49
56
|
writeState,
|
|
50
57
|
} from "../lib/tunnel/state.ts";
|
|
@@ -335,53 +342,6 @@ function sleep(ms: number): Promise<void> {
|
|
|
335
342
|
return new Promise((r) => setTimeout(r, ms));
|
|
336
343
|
}
|
|
337
344
|
|
|
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
|
-
|
|
385
345
|
/**
|
|
386
346
|
* Pick a gate port for a new instance. An explicit `--gate-port` is honored
|
|
387
347
|
* (and rejected if it's already taken); otherwise scan upward from 9001 for the
|
|
@@ -389,7 +349,7 @@ function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
|
|
|
389
349
|
*/
|
|
390
350
|
async function allocateGatePort(preferred: number | undefined): Promise<number> {
|
|
391
351
|
const used = new Set<number>(
|
|
392
|
-
|
|
352
|
+
listSelectedStates()
|
|
393
353
|
.filter((s) => isProcessAlive(s.gate_pid))
|
|
394
354
|
.map((s) => s.gate_port),
|
|
395
355
|
);
|
|
@@ -522,13 +482,14 @@ function runTailscaleShare(
|
|
|
522
482
|
}
|
|
523
483
|
|
|
524
484
|
function stopTailscaleShare(state: TunnelState): boolean {
|
|
525
|
-
if (state.provider !== "tailscale"
|
|
526
|
-
|
|
485
|
+
if (state.provider !== "tailscale") return true;
|
|
486
|
+
if (!originalTailscaleRoute(state)) return false;
|
|
487
|
+
const cmd = tailscaleCommand(state.tailscale_mode!);
|
|
527
488
|
const logPath = tunnelLogDestinations(state.name, "tailscale").provider;
|
|
528
489
|
const args = [
|
|
529
490
|
cmd,
|
|
530
|
-
`--https=${state.tailscale_https_port
|
|
531
|
-
`--set-path=${state.tailscale_path
|
|
491
|
+
`--https=${state.tailscale_https_port}`,
|
|
492
|
+
`--set-path=${state.tailscale_path}`,
|
|
532
493
|
"off",
|
|
533
494
|
];
|
|
534
495
|
const status = runRotatingProcessSync({
|
|
@@ -541,9 +502,9 @@ function stopTailscaleShare(state: TunnelState): boolean {
|
|
|
541
502
|
// surviving serve/funnel mapping could later re-expose whatever next binds
|
|
542
503
|
// that port. Surface it loudly so the operator can clear it by hand.
|
|
543
504
|
emit.text(
|
|
544
|
-
`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path
|
|
505
|
+
`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path}).\n` +
|
|
545
506
|
` It may still be exposing this machine. Clear it with:\n` +
|
|
546
|
-
` tailscale ${cmd} --https=${state.tailscale_https_port
|
|
507
|
+
` tailscale ${cmd} --https=${state.tailscale_https_port} --set-path=${state.tailscale_path} off\n` +
|
|
547
508
|
` Log: ${logPath}\n\n`,
|
|
548
509
|
);
|
|
549
510
|
return false;
|
|
@@ -559,13 +520,48 @@ function contextVhost(): string | null {
|
|
|
559
520
|
}
|
|
560
521
|
|
|
561
522
|
function providerIsAlive(state: TunnelState): boolean {
|
|
562
|
-
if (state
|
|
563
|
-
|
|
523
|
+
if (!canonicalProviderFields(state)) return false;
|
|
524
|
+
if (state.provider === "tailscale") return state.provider_state === "ready";
|
|
525
|
+
const pid = state.cloudflared_pid;
|
|
564
526
|
return typeof pid === "number" && isProcessAlive(pid);
|
|
565
527
|
}
|
|
566
528
|
|
|
567
|
-
function
|
|
568
|
-
return
|
|
529
|
+
function canonicalProviderFields(state: TunnelState): boolean {
|
|
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() }));
|
|
569
565
|
}
|
|
570
566
|
|
|
571
567
|
interface GateSpawnOpts {
|
|
@@ -588,8 +584,7 @@ interface GateSpawnOpts {
|
|
|
588
584
|
* re-reads — that snapshot is precisely why `reload` has to exist.
|
|
589
585
|
*/
|
|
590
586
|
function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
|
|
591
|
-
//
|
|
592
|
-
// process distinguishable per-instance in a command-line search (see sweepStrays).
|
|
587
|
+
// Keep the instance name and port visible in process diagnostics.
|
|
593
588
|
const gateProc = spawnRotatingProcess({
|
|
594
589
|
path: o.gateLogPath,
|
|
595
590
|
command: launch.command,
|
|
@@ -610,7 +605,115 @@ function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
|
|
|
610
605
|
return gateProc;
|
|
611
606
|
}
|
|
612
607
|
|
|
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
|
+
|
|
613
707
|
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> {
|
|
614
717
|
const launch = currentGateLaunch();
|
|
615
718
|
if (!launch) {
|
|
616
719
|
emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
|
|
@@ -630,8 +733,17 @@ async function up(opts: UpOpts): Promise<void> {
|
|
|
630
733
|
provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
|
|
631
734
|
const allowPaths = resolveAllowPaths(opts.allowPath);
|
|
632
735
|
|
|
633
|
-
const existing =
|
|
634
|
-
|
|
736
|
+
const existing = readSelectedState(name);
|
|
737
|
+
const readiness = existing ? readinessOf(existing) : null;
|
|
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") {
|
|
635
747
|
if (existing.provider !== provider) {
|
|
636
748
|
emit.error({
|
|
637
749
|
code: "tunnel_instance_in_use",
|
|
@@ -654,16 +766,21 @@ async function up(opts: UpOpts): Promise<void> {
|
|
|
654
766
|
emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
|
|
655
767
|
return;
|
|
656
768
|
}
|
|
657
|
-
if (existing)
|
|
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
|
+
}
|
|
658
779
|
|
|
659
780
|
// Allocate the gate port (after clearing dead state so its old port frees up
|
|
660
781
|
// for reuse). Explicit --gate-port is validated; otherwise auto-scan.
|
|
661
782
|
const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
|
|
662
783
|
|
|
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
|
-
|
|
667
784
|
const cfg = readConfig();
|
|
668
785
|
if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
|
|
669
786
|
emit.error({
|
|
@@ -711,17 +828,13 @@ async function up(opts: UpOpts): Promise<void> {
|
|
|
711
828
|
launch,
|
|
712
829
|
);
|
|
713
830
|
|
|
831
|
+
const gateStartToken = processStartToken(gateProc.pid!);
|
|
714
832
|
await sleep(800);
|
|
715
833
|
|
|
716
834
|
// A host CLI that re-executes itself takes longer to load than a bare script,
|
|
717
835
|
// so wait for the gate to actually listen before the provider forwards to it.
|
|
718
836
|
if (!isProcessAlive(gateProc.pid!) || !(await waitForPortBound(gatePort, 15_000))) {
|
|
719
|
-
|
|
720
|
-
process.kill(gateProc.pid!);
|
|
721
|
-
} catch {
|
|
722
|
-
/* already dead */
|
|
723
|
-
}
|
|
724
|
-
sweepStrays(gatePort, new Set());
|
|
837
|
+
signalIfSameGeneration(gateProc.pid!, gateStartToken, "SIGTERM");
|
|
725
838
|
emit.error({
|
|
726
839
|
code: "tunnel_gate_failed",
|
|
727
840
|
message: `Gate failed to start. Check log: ${gateLogPath}`,
|
|
@@ -732,6 +845,8 @@ async function up(opts: UpOpts): Promise<void> {
|
|
|
732
845
|
let url: string;
|
|
733
846
|
let registered = true;
|
|
734
847
|
let cloudflaredPid: number | undefined;
|
|
848
|
+
let cloudflaredStartToken: string | undefined;
|
|
849
|
+
let pendingReceipt: ServerRecord | null = null;
|
|
735
850
|
|
|
736
851
|
if (provider === "cloudflare") {
|
|
737
852
|
// Force HTTP/2 transport. The default QUIC transport wedges at precheck on
|
|
@@ -747,20 +862,13 @@ async function up(opts: UpOpts): Promise<void> {
|
|
|
747
862
|
});
|
|
748
863
|
cfdProc.unref();
|
|
749
864
|
cloudflaredPid = cfdProc.pid!;
|
|
865
|
+
cloudflaredStartToken = processStartToken(cloudflaredPid) ?? undefined;
|
|
750
866
|
|
|
751
867
|
const ready = await waitForReady(providerLogPath, 30_000);
|
|
752
868
|
registered = ready.registered;
|
|
753
869
|
if (!ready.url) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
} catch {
|
|
757
|
-
/* already dead */
|
|
758
|
-
}
|
|
759
|
-
try {
|
|
760
|
-
process.kill(cloudflaredPid);
|
|
761
|
-
} catch {
|
|
762
|
-
/* already dead */
|
|
763
|
-
}
|
|
870
|
+
signalIfSameGeneration(gateProc.pid!, gateStartToken, "SIGTERM");
|
|
871
|
+
signalIfSameGeneration(cloudflaredPid, cloudflaredStartToken, "SIGTERM");
|
|
764
872
|
emit.error({
|
|
765
873
|
code: "tunnel_url_timeout",
|
|
766
874
|
message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
|
|
@@ -776,12 +884,38 @@ async function up(opts: UpOpts): Promise<void> {
|
|
|
776
884
|
// `heal` would then be unable to see or clean up.
|
|
777
885
|
url = tailscaleUrl(tailscalePath!, tailscaleHttpsPort!);
|
|
778
886
|
const gateTarget = `http://127.0.0.1:${gatePort}`;
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
887
|
+
const pending: TunnelState = {
|
|
888
|
+
name,
|
|
889
|
+
provider,
|
|
890
|
+
url,
|
|
891
|
+
gate_pid: gateProc.pid!,
|
|
892
|
+
gate_start_token: gateStartToken ?? undefined,
|
|
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
|
+
),
|
|
785
919
|
);
|
|
786
920
|
}
|
|
787
921
|
|
|
@@ -789,7 +923,10 @@ async function up(opts: UpOpts): Promise<void> {
|
|
|
789
923
|
name,
|
|
790
924
|
provider,
|
|
791
925
|
url,
|
|
926
|
+
...(provider === "tailscale" ? { provider_state: "ready" as const } : {}),
|
|
792
927
|
gate_pid: gateProc.pid!,
|
|
928
|
+
gate_start_token: gateStartToken ?? undefined,
|
|
929
|
+
cloudflared_start_token: cloudflaredStartToken,
|
|
793
930
|
cloudflared_pid: cloudflaredPid,
|
|
794
931
|
started_at: new Date().toISOString(),
|
|
795
932
|
target,
|
|
@@ -800,8 +937,22 @@ async function up(opts: UpOpts): Promise<void> {
|
|
|
800
937
|
tailscale_path: tailscalePath,
|
|
801
938
|
tailscale_https_port: tailscaleHttpsPort,
|
|
802
939
|
};
|
|
803
|
-
|
|
804
|
-
|
|
940
|
+
const receipt = provider === "tailscale" ? pendingReceipt : recordTunnelServer(state);
|
|
941
|
+
if (receipt) state.registration_id = receipt.registration_id;
|
|
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
|
+
}
|
|
805
956
|
|
|
806
957
|
const bin = resolveBinName();
|
|
807
958
|
const stopHint =
|
|
@@ -836,43 +987,191 @@ function registryRoot(): string {
|
|
|
836
987
|
return resolveCoordRoot() ?? process.cwd();
|
|
837
988
|
}
|
|
838
989
|
|
|
839
|
-
function
|
|
840
|
-
|
|
841
|
-
|
|
990
|
+
function unknownTunnelFrame(name: string): Error {
|
|
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
|
+
);
|
|
842
1062
|
}
|
|
843
1063
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
(
|
|
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
|
|
848
1076
|
);
|
|
849
1077
|
}
|
|
850
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;
|
|
1107
|
+
const coordRoot = registryRoot();
|
|
1108
|
+
const record = tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
|
|
1109
|
+
if (record) state.registration_id = record.registration_id;
|
|
1110
|
+
return record;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function tunnelRegistration(state: TunnelState): { known: boolean; record: ServerRecord | null } {
|
|
1114
|
+
if (!originalProcessWitnesses(state)) return { known: false, record: null };
|
|
1115
|
+
const coordRoot = registryRoot();
|
|
1116
|
+
const id = tunnelServerId(state.name);
|
|
1117
|
+
let record: ServerRecord | null;
|
|
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 };
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** Unknown saved identities retain state and never authorize a signal. */
|
|
851
1129
|
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 {
|
|
852
1138
|
const before = killed.size;
|
|
853
|
-
const state =
|
|
854
|
-
if (state)
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1139
|
+
const state = readSelectedState(name);
|
|
1140
|
+
if (!state) return 0;
|
|
1141
|
+
const registration = tunnelRegistration(state);
|
|
1142
|
+
if (!registration.known) return 0;
|
|
1143
|
+
const targets = [
|
|
1144
|
+
{ pid: state.gate_pid, token: state.gate_start_token },
|
|
1145
|
+
{ pid: state.cloudflared_pid, token: state.cloudflared_start_token },
|
|
1146
|
+
].filter(
|
|
1147
|
+
(target): target is { pid: number; token: string | undefined } =>
|
|
1148
|
+
typeof target.pid === "number",
|
|
1149
|
+
);
|
|
1150
|
+
for (const target of targets) {
|
|
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);
|
|
865
1163
|
}
|
|
866
|
-
|
|
1164
|
+
// Observation and deletion share custody; an old stopper cannot clear a winner.
|
|
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;
|
|
867
1173
|
}
|
|
868
|
-
|
|
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() });
|
|
1174
|
+
if (cleared) unregisterServer(registration.record, { coordRoot: registryRoot() });
|
|
876
1175
|
return killed.size - before;
|
|
877
1176
|
}
|
|
878
1177
|
|
|
@@ -880,14 +1179,15 @@ function down(opts: DownOpts): void {
|
|
|
880
1179
|
const killed = new Set<number>();
|
|
881
1180
|
|
|
882
1181
|
if (opts.all) {
|
|
883
|
-
const states =
|
|
1182
|
+
const states = listSelectedStates();
|
|
884
1183
|
if (states.length === 0) {
|
|
885
1184
|
emit.text("No tunnels up. Nothing to stop.\n");
|
|
886
1185
|
return;
|
|
887
1186
|
}
|
|
888
1187
|
for (const s of states) downOne(s.name, killed);
|
|
1188
|
+
const retained = states.filter((state) => readSelectedState(state.name)).length;
|
|
889
1189
|
emit.text(
|
|
890
|
-
`
|
|
1190
|
+
`Requested stop for ${states.length} tunnel(s); ${killed.size} process signal(s), ${retained} state record(s) retained pending verified exit.\n`,
|
|
891
1191
|
);
|
|
892
1192
|
return;
|
|
893
1193
|
}
|
|
@@ -895,8 +1195,8 @@ function down(opts: DownOpts): void {
|
|
|
895
1195
|
const name = resolveName(opts.name);
|
|
896
1196
|
// Bare `down` targets the default instance. If it's not up but named ones
|
|
897
1197
|
// are, don't silently no-op; point the operator at them.
|
|
898
|
-
if (name === DEFAULT_INSTANCE && !
|
|
899
|
-
const others =
|
|
1198
|
+
if (name === DEFAULT_INSTANCE && !readSelectedState(DEFAULT_INSTANCE)) {
|
|
1199
|
+
const others = listSelectedStates();
|
|
900
1200
|
if (others.length > 0) {
|
|
901
1201
|
emit.text(
|
|
902
1202
|
`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`,
|
|
@@ -907,9 +1207,9 @@ function down(opts: DownOpts): void {
|
|
|
907
1207
|
|
|
908
1208
|
downOne(name, killed);
|
|
909
1209
|
emit.text(
|
|
910
|
-
|
|
911
|
-
? `
|
|
912
|
-
: `
|
|
1210
|
+
readSelectedState(name)
|
|
1211
|
+
? `Tunnel [${name}] state retained pending verified process exit or custody; ${killed.size} signal(s) sent.\n`
|
|
1212
|
+
: `Tunnel [${name}] has no remaining recorded processes.\n`,
|
|
913
1213
|
);
|
|
914
1214
|
}
|
|
915
1215
|
|
|
@@ -945,10 +1245,29 @@ async function waitForPortBound(port: number, timeoutMs: number): Promise<boolea
|
|
|
945
1245
|
* edge reconnects without noticing.
|
|
946
1246
|
*/
|
|
947
1247
|
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 }> {
|
|
948
1261
|
const bin = resolveBinName();
|
|
949
1262
|
const name = state.name;
|
|
950
1263
|
const gatePort = state.gate_port;
|
|
951
1264
|
|
|
1265
|
+
if (!canonicalProviderFields(state))
|
|
1266
|
+
return {
|
|
1267
|
+
ok: false,
|
|
1268
|
+
message: `[${name}] obsolete or ambiguous provider authority is unknown; retained the tunnel.`,
|
|
1269
|
+
};
|
|
1270
|
+
|
|
952
1271
|
if (!providerIsAlive(state)) {
|
|
953
1272
|
return {
|
|
954
1273
|
ok: false,
|
|
@@ -974,28 +1293,54 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
|
|
|
974
1293
|
};
|
|
975
1294
|
}
|
|
976
1295
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1296
|
+
const registration = tunnelRegistration(state);
|
|
1297
|
+
if (!registration.known || !registration.record)
|
|
1298
|
+
return { ok: false, message: `[${name}] registration custody changed; retained the tunnel.` };
|
|
1299
|
+
const stopGate = async (): Promise<{ ok: boolean; message: string }> => {
|
|
1300
|
+
if (
|
|
1301
|
+
state.provider === "cloudflare" &&
|
|
1302
|
+
state.cloudflared_pid &&
|
|
1303
|
+
verifyProcessGeneration(state.cloudflared_pid, state.cloudflared_start_token) !== "same"
|
|
1304
|
+
)
|
|
1305
|
+
return {
|
|
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
|
+
};
|
|
987
1321
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1322
|
+
if (!(await waitForPortFree(gatePort, 5_000)))
|
|
1323
|
+
return {
|
|
1324
|
+
ok: false,
|
|
1325
|
+
message: `[${name}] port ${gatePort} never released; retained the tunnel state.`,
|
|
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 {
|
|
992
1337
|
return {
|
|
993
1338
|
ok: false,
|
|
994
|
-
message:
|
|
995
|
-
`[${name}] port ${gatePort} never released, so the old gate looks wedged. ` +
|
|
996
|
-
`Tunnel is down; check \`${bin} tunnel status\`.`,
|
|
1339
|
+
message: `[${name}] registration custody is unavailable; retained the tunnel.`,
|
|
997
1340
|
};
|
|
998
1341
|
}
|
|
1342
|
+
if (!released.ok) return released;
|
|
1343
|
+
if (registration.record) unregisterServer(registration.record, { coordRoot: registryRoot() });
|
|
999
1344
|
|
|
1000
1345
|
const cfg = readConfig();
|
|
1001
1346
|
const gateProc = spawnGate(
|
|
@@ -1012,6 +1357,7 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
|
|
|
1012
1357
|
launch,
|
|
1013
1358
|
);
|
|
1014
1359
|
|
|
1360
|
+
const gateStartToken = processStartToken(gateProc.pid!);
|
|
1015
1361
|
await sleep(800);
|
|
1016
1362
|
const pid = gateProc.pid;
|
|
1017
1363
|
if (
|
|
@@ -1027,8 +1373,24 @@ export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; mess
|
|
|
1027
1373
|
};
|
|
1028
1374
|
}
|
|
1029
1375
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1376
|
+
const next = {
|
|
1377
|
+
...state,
|
|
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
|
+
}
|
|
1032
1394
|
const allowNote =
|
|
1033
1395
|
state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
|
|
1034
1396
|
return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
|
|
@@ -1038,7 +1400,7 @@ async function reload(opts: ReloadOpts): Promise<void> {
|
|
|
1038
1400
|
const bin = resolveBinName();
|
|
1039
1401
|
|
|
1040
1402
|
if (opts.all) {
|
|
1041
|
-
const all =
|
|
1403
|
+
const all = listSelectedStates();
|
|
1042
1404
|
// Only live tunnels are reloadable, and a box commonly accumulates stale
|
|
1043
1405
|
// state files from long-dead runs. Reporting those as failures would make
|
|
1044
1406
|
// the common post-`allow add` path exit non-zero for no real reason.
|
|
@@ -1067,7 +1429,7 @@ async function reload(opts: ReloadOpts): Promise<void> {
|
|
|
1067
1429
|
}
|
|
1068
1430
|
|
|
1069
1431
|
const name = resolveName(opts.name);
|
|
1070
|
-
const state =
|
|
1432
|
+
const state = readSelectedState(name);
|
|
1071
1433
|
if (!state) {
|
|
1072
1434
|
emit.error({
|
|
1073
1435
|
code: "tunnel_not_found",
|
|
@@ -1081,8 +1443,9 @@ async function reload(opts: ReloadOpts): Promise<void> {
|
|
|
1081
1443
|
if (!r.ok) process.exit(1);
|
|
1082
1444
|
}
|
|
1083
1445
|
|
|
1084
|
-
function instanceState(state: TunnelState): "up" | "stale" {
|
|
1085
|
-
|
|
1446
|
+
function instanceState(state: TunnelState): "up" | "stale" | "unknown" {
|
|
1447
|
+
const readiness = readinessOf(state);
|
|
1448
|
+
return readiness === "ready" ? "up" : readiness === "dead" ? "stale" : "unknown";
|
|
1086
1449
|
}
|
|
1087
1450
|
|
|
1088
1451
|
function fmtUptime(startedAt: string): string {
|
|
@@ -1097,9 +1460,9 @@ function fmtUptime(startedAt: string): string {
|
|
|
1097
1460
|
function statusDetail(state: TunnelState): void {
|
|
1098
1461
|
const gateAlive = isProcessAlive(state.gate_pid);
|
|
1099
1462
|
const providerAlive = providerIsAlive(state);
|
|
1100
|
-
const providerPid = state.cloudflared_pid
|
|
1463
|
+
const providerPid = state.cloudflared_pid;
|
|
1101
1464
|
const cfg = readConfig();
|
|
1102
|
-
emit.text(`${
|
|
1465
|
+
emit.text(`${instanceState(state)} [${state.name}]\n`);
|
|
1103
1466
|
emit.text(
|
|
1104
1467
|
` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`,
|
|
1105
1468
|
);
|
|
@@ -1122,7 +1485,7 @@ function statusDetail(state: TunnelState): void {
|
|
|
1122
1485
|
function status(opts: StatusOpts): void {
|
|
1123
1486
|
// Named → detailed single block.
|
|
1124
1487
|
if (opts.name) {
|
|
1125
|
-
const state =
|
|
1488
|
+
const state = readSelectedState(resolveName(opts.name));
|
|
1126
1489
|
if (!state) {
|
|
1127
1490
|
emit.text(`down [${resolveName(opts.name)}]\n`);
|
|
1128
1491
|
return;
|
|
@@ -1132,7 +1495,7 @@ function status(opts: StatusOpts): void {
|
|
|
1132
1495
|
}
|
|
1133
1496
|
|
|
1134
1497
|
// No name → table of every instance.
|
|
1135
|
-
const states =
|
|
1498
|
+
const states = listSelectedStates();
|
|
1136
1499
|
if (states.length === 0) {
|
|
1137
1500
|
emit.text("down\n");
|
|
1138
1501
|
return;
|
|
@@ -1175,7 +1538,7 @@ function status(opts: StatusOpts): void {
|
|
|
1175
1538
|
|
|
1176
1539
|
function logs(opts: LogsOpts): void {
|
|
1177
1540
|
const name = resolveName(opts.name);
|
|
1178
|
-
const state =
|
|
1541
|
+
const state = readSelectedState(name);
|
|
1179
1542
|
const provider = state?.provider ?? "cloudflare";
|
|
1180
1543
|
const destinations = tunnelLogDestinations(name, provider);
|
|
1181
1544
|
const path = opts.provider ? destinations.provider : destinations.gate;
|
|
@@ -1212,7 +1575,9 @@ const sameEntryIn = (list: readonly string[], entry: string) =>
|
|
|
1212
1575
|
* restarts were owed.
|
|
1213
1576
|
*/
|
|
1214
1577
|
function allowChangedHint(): void {
|
|
1215
|
-
const live =
|
|
1578
|
+
const live = listSelectedStates().filter(
|
|
1579
|
+
(s) => s.provider === "cloudflare" && providerIsAlive(s),
|
|
1580
|
+
);
|
|
1216
1581
|
if (live.length === 0) return;
|
|
1217
1582
|
emit.text(
|
|
1218
1583
|
`Each gate reads the allowlist once at start, so this applies on reload ` +
|
|
@@ -1354,7 +1719,9 @@ export async function refreshCurrentAddress(
|
|
|
1354
1719
|
}
|
|
1355
1720
|
if (!plan.changed || !options.reload) return result;
|
|
1356
1721
|
|
|
1357
|
-
const live =
|
|
1722
|
+
const live = listSelectedStates().filter(
|
|
1723
|
+
(s) => s.provider === "cloudflare" && providerIsAlive(s),
|
|
1724
|
+
);
|
|
1358
1725
|
for (const s of live) {
|
|
1359
1726
|
const r = await reloadOne(s);
|
|
1360
1727
|
emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
|