harnery 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commander.d.ts +16 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +21 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +63 -5
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +113 -12
- package/dist/commands/browse.d.ts +164 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +263 -51
- package/dist/commands/claude-desktop.d.ts.map +1 -1
- package/dist/commands/claude-desktop.js +52 -0
- package/dist/commands/disk.d.ts +6 -0
- package/dist/commands/disk.d.ts.map +1 -0
- package/dist/commands/disk.js +80 -0
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +48 -1
- package/dist/commands/files.d.ts +16 -0
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +54 -2
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +43 -9
- package/dist/commands/rm.d.ts +4 -0
- package/dist/commands/rm.d.ts.map +1 -0
- package/dist/commands/rm.js +87 -0
- package/dist/commands/servers.d.ts +7 -0
- package/dist/commands/servers.d.ts.map +1 -0
- package/dist/commands/servers.js +394 -0
- package/dist/commands/tunnel.d.ts +65 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +372 -103
- package/dist/commands/web-fetch.d.ts +3 -2
- package/dist/commands/web-fetch.d.ts.map +1 -1
- package/dist/commands/web-fetch.js +25 -19
- package/dist/commands/web.d.ts +7 -1
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +9 -3
- package/dist/core/adapters/profiles.d.ts +9 -0
- package/dist/core/adapters/profiles.d.ts.map +1 -1
- package/dist/core/adapters/profiles.js +22 -1
- package/dist/core/adapters/types.d.ts +9 -0
- package/dist/core/adapters/types.d.ts.map +1 -1
- package/dist/core/agents/cli.js +13 -6
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +11 -5
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +59 -13
- package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +60 -0
- package/dist/core/agents/state/names.d.ts +43 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +87 -3
- package/dist/core/artifacts/delivery-card.js +9 -2
- package/dist/core/artifacts/index.d.ts +140 -4
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +522 -49
- package/dist/core/config.d.ts +68 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +128 -3
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +2 -2
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +16 -0
- package/dist/core/events/v3/projection.js +9 -1
- package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
- package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
- package/dist/core/hooks/adapter/events.d.ts +17 -0
- package/dist/core/hooks/adapter/events.d.ts.map +1 -1
- package/dist/core/hooks/adapter/events.js +5 -0
- package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
- package/dist/core/hooks/adapter/wiring.d.ts +37 -7
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +62 -4
- package/dist/core/hooks/cli.js +131 -23
- package/dist/core/hooks/fork-lineage.d.ts +40 -0
- package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
- package/dist/core/hooks/fork-lineage.js +62 -0
- package/dist/core/hooks/resolve/transcript.d.ts +16 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +84 -0
- package/dist/core/hooks/run-markers.d.ts +37 -0
- package/dist/core/hooks/run-markers.d.ts.map +1 -0
- package/dist/core/hooks/run-markers.js +109 -0
- package/dist/core/hooks/shell-rm.d.ts +2 -0
- package/dist/core/hooks/shell-rm.d.ts.map +1 -0
- package/dist/core/hooks/shell-rm.js +105 -0
- package/dist/core/hooks/shell-tokens.d.ts +12 -0
- package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
- package/dist/core/hooks/shell-tokens.js +97 -0
- package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
- package/dist/core/hooks/shell-waiter.js +3 -93
- package/dist/core/semantic/contract.d.ts +5 -5
- package/dist/core/servers/index.d.ts +211 -0
- package/dist/core/servers/index.d.ts.map +1 -0
- package/dist/core/servers/index.js +560 -0
- package/dist/core/servers/net.d.ts +58 -0
- package/dist/core/servers/net.d.ts.map +1 -0
- package/dist/core/servers/net.js +270 -0
- package/dist/core/servers/tunnels.d.ts +14 -0
- package/dist/core/servers/tunnels.d.ts.map +1 -0
- package/dist/core/servers/tunnels.js +33 -0
- package/dist/core/storage/builtins.js +1 -0
- package/dist/core/storage/process-log.d.ts +12 -0
- package/dist/core/storage/process-log.d.ts.map +1 -1
- package/dist/core/storage/process-log.js +10 -2
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +30 -1
- package/dist/core/workflow/sandbox-projection.d.ts +13 -2
- package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
- package/dist/core/workflow/sandbox-projection.js +23 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +6 -1
- package/dist/core/workflow/spawn-codex.d.ts +2 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +20 -1
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +6 -1
- package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
- package/dist/core/workflow/spawn-opencode.js +4 -1
- package/dist/core/workflow/types.d.ts +47 -2
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/worker-access.d.ts +92 -0
- package/dist/core/workflow/worker-access.d.ts.map +1 -0
- package/dist/core/workflow/worker-access.js +450 -0
- package/dist/lib/browser/batch-steps.d.ts +18 -0
- package/dist/lib/browser/batch-steps.d.ts.map +1 -0
- package/dist/lib/browser/batch-steps.js +82 -0
- package/dist/lib/browser/collect/collector.d.ts +99 -0
- package/dist/lib/browser/collect/collector.d.ts.map +1 -0
- package/dist/lib/browser/collect/collector.js +202 -0
- package/dist/lib/browser/collect/fields.d.ts +31 -0
- package/dist/lib/browser/collect/fields.d.ts.map +1 -0
- package/dist/lib/browser/collect/fields.js +75 -0
- package/dist/lib/browser/collect/index.d.ts +6 -0
- package/dist/lib/browser/collect/index.d.ts.map +1 -0
- package/dist/lib/browser/collect/index.js +5 -0
- package/dist/lib/browser/collect/page-driver.d.ts +14 -0
- package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
- package/dist/lib/browser/collect/page-driver.js +306 -0
- package/dist/lib/browser/collect/presets.d.ts +52 -0
- package/dist/lib/browser/collect/presets.d.ts.map +1 -0
- package/dist/lib/browser/collect/presets.js +176 -0
- package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
- package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
- package/dist/lib/browser/collect/scroll-plan.js +118 -0
- package/dist/lib/browser/index.d.ts +1 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.d.ts +2 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.js +13 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +1 -0
- package/dist/lib/browser/windows-chrome.d.ts +59 -0
- package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
- package/dist/lib/browser/windows-chrome.js +179 -0
- package/dist/lib/claude-desktop-share.d.ts +136 -0
- package/dist/lib/claude-desktop-share.d.ts.map +1 -0
- package/dist/lib/claude-desktop-share.js +394 -0
- package/dist/lib/disk-usage.d.ts +50 -0
- package/dist/lib/disk-usage.d.ts.map +1 -0
- package/dist/lib/disk-usage.js +303 -0
- package/dist/lib/docs-lint.d.ts +6 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +8 -3
- package/dist/lib/guarded-remove.d.ts +34 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -0
- package/dist/lib/guarded-remove.js +310 -0
- package/dist/lib/instructions/templates.js +8 -8
- package/dist/lib/tunnel/allowlist.d.ts +44 -0
- package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
- package/dist/lib/tunnel/allowlist.js +204 -0
- package/dist/lib/tunnel/current-address.d.ts +41 -0
- package/dist/lib/tunnel/current-address.d.ts.map +1 -0
- package/dist/lib/tunnel/current-address.js +123 -0
- package/dist/lib/tunnel/error-page.d.ts +1 -1
- package/dist/lib/tunnel/error-page.d.ts.map +1 -1
- package/dist/lib/tunnel/error-page.js +19 -8
- package/dist/lib/tunnel/gate.js +33 -7
- package/dist/lib/tunnel/path-scope.d.ts +22 -0
- package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
- package/dist/lib/tunnel/path-scope.js +62 -0
- package/dist/lib/tunnel/processes.d.ts +39 -0
- package/dist/lib/tunnel/processes.d.ts.map +1 -0
- package/dist/lib/tunnel/processes.js +121 -0
- package/dist/lib/tunnel/state.d.ts +33 -3
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +115 -30
- package/package.json +7 -1
- package/schemas/config.schema.json +91 -0
- package/src/commander.ts +51 -0
- package/src/commands/agents.ts +111 -11
- package/src/commands/artifacts.ts +171 -19
- package/src/commands/browse.ts +400 -57
- package/src/commands/claude-desktop.ts +61 -0
- package/src/commands/disk.ts +114 -0
- package/src/commands/doctor.ts +52 -1
- package/src/commands/files.ts +93 -2
- package/src/commands/init.ts +55 -5
- package/src/commands/rm.ts +115 -0
- package/src/commands/servers.ts +506 -0
- package/src/commands/tunnel.ts +494 -116
- package/src/commands/web-fetch.ts +30 -19
- package/src/commands/web.ts +9 -3
- package/src/core/adapters/profiles.ts +22 -1
- package/src/core/adapters/types.ts +10 -0
- package/src/core/agents/cli.ts +14 -6
- package/src/core/agents/render/prompt-context.ts +25 -8
- package/src/core/agents/render/session-context.ts +84 -16
- package/src/core/agents/state/live-coordination-view.ts +73 -0
- package/src/core/agents/state/names.ts +117 -6
- package/src/core/artifacts/delivery-card.ts +10 -2
- package/src/core/artifacts/index.ts +697 -44
- package/src/core/config.ts +183 -4
- package/src/core/diagnostics/bundle.ts +2 -1
- package/src/core/events/v3/producers/recorder.ts +16 -0
- package/src/core/events/v3/projection.ts +11 -1
- package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
- package/src/core/hooks/adapter/events.ts +19 -0
- package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
- package/src/core/hooks/adapter/wiring.ts +116 -8
- package/src/core/hooks/cli.ts +152 -22
- package/src/core/hooks/fork-lineage.ts +82 -0
- package/src/core/hooks/resolve/transcript.ts +88 -0
- package/src/core/hooks/run-markers.ts +132 -0
- package/src/core/hooks/shell-rm.ts +103 -0
- package/src/core/hooks/shell-tokens.ts +96 -0
- package/src/core/hooks/shell-waiter.ts +4 -91
- package/src/core/servers/index.ts +712 -0
- package/src/core/servers/net.ts +283 -0
- package/src/core/servers/tunnels.ts +39 -0
- package/src/core/storage/builtins.ts +1 -0
- package/src/core/storage/process-log.ts +17 -2
- package/src/core/workflow/engine.ts +38 -1
- package/src/core/workflow/sandbox-projection.ts +38 -2
- package/src/core/workflow/spawn-claude.ts +5 -1
- package/src/core/workflow/spawn-codex.ts +18 -1
- package/src/core/workflow/spawn-cursor.ts +5 -1
- package/src/core/workflow/spawn-opencode.ts +4 -1
- package/src/core/workflow/types.ts +62 -2
- package/src/core/workflow/worker-access.ts +525 -0
- package/src/lib/browser/batch-steps.ts +79 -0
- package/src/lib/browser/collect/collector.ts +306 -0
- package/src/lib/browser/collect/fields.ts +87 -0
- package/src/lib/browser/collect/index.ts +47 -0
- package/src/lib/browser/collect/page-driver.ts +327 -0
- package/src/lib/browser/collect/presets.ts +237 -0
- package/src/lib/browser/collect/scroll-plan.ts +179 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/page-review-judge.ts +16 -0
- package/src/lib/browser/qa-run-contracts.ts +11 -0
- package/src/lib/browser/qa-run.ts +1 -0
- package/src/lib/browser/windows-chrome.ts +227 -0
- package/src/lib/claude-desktop-share.ts +511 -0
- package/src/lib/disk-usage.ts +347 -0
- package/src/lib/docs-lint.ts +8 -3
- package/src/lib/guarded-remove.ts +376 -0
- package/src/lib/instructions/templates.ts +8 -8
- package/src/lib/tunnel/allowlist.ts +208 -0
- package/src/lib/tunnel/current-address.ts +158 -0
- package/src/lib/tunnel/error-page.ts +25 -9
- package/src/lib/tunnel/gate.ts +38 -9
- package/src/lib/tunnel/path-scope.ts +73 -0
- package/src/lib/tunnel/processes.ts +149 -0
- package/src/lib/tunnel/state.ts +143 -30
package/dist/commands/tunnel.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { resolveCoordRoot } from "../core/agents/coord-client.js";
|
|
4
5
|
import { resolveBinName } from "../core/config.js";
|
|
5
|
-
import {
|
|
6
|
+
import { tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, } from "../core/servers/index.js";
|
|
7
|
+
import { processLogDestination, runProcessLogWorker, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
|
|
8
|
+
import { normalizeAllowEntry } from "../lib/tunnel/allowlist.js";
|
|
9
|
+
import { detectPublicAddresses, planCurrentAddresses, } from "../lib/tunnel/current-address.js";
|
|
10
|
+
import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.js";
|
|
11
|
+
import { connectProbe, findPidsByCommandLine, listeningPorts, portListening, } from "../lib/tunnel/processes.js";
|
|
6
12
|
import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, isTunnelStateLive, listStates, providerLogFile, readConfig, readState, writeConfig, writeState, } from "../lib/tunnel/state.js";
|
|
7
13
|
/**
|
|
8
14
|
* `tunnel`: provider-backed tunnel in front of a local upstream.
|
|
@@ -11,10 +17,14 @@ import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcess
|
|
|
11
17
|
* upstream. Cloudflare quick tunnels add an IP allowlist at the gate via the
|
|
12
18
|
* Cloudflare-set `CF-Connecting-IP` header; Tailscale Serve/Funnel exposes the
|
|
13
19
|
* same gate through tailscaled, with Tailscale owning the access boundary.
|
|
20
|
+
* Every provider also passes through a path scope: `up` requires at least one
|
|
21
|
+
* `--allow-path <prefix>`, and the gate refuses any request outside it.
|
|
14
22
|
*
|
|
15
|
-
* State + config persisted under `.cache/tunnel
|
|
16
|
-
*
|
|
17
|
-
*
|
|
23
|
+
* State + config persisted under `.cache/tunnel/` (or `$HARNERY_TUNNEL_DIR`).
|
|
24
|
+
* cloudflared comes from `$HARNERY_CLOUDFLARED` / the config's `cloudflared_path`
|
|
25
|
+
* when set, else PATH, else auto-installs to ~/.local/bin/ on Linux (macOS and
|
|
26
|
+
* Windows hosts install it themselves); Tailscale requires an installed and
|
|
27
|
+
* authenticated `tailscale` CLI.
|
|
18
28
|
*/
|
|
19
29
|
const DEFAULT_TARGET = "127.0.0.1:8001";
|
|
20
30
|
const DEFAULT_VHOST = "localhost";
|
|
@@ -98,38 +108,126 @@ function resolveHttpsPort(raw) {
|
|
|
98
108
|
}
|
|
99
109
|
return port;
|
|
100
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* The path scope for `up`. At least one prefix is required: a tunnel forwards
|
|
113
|
+
* to a whole local app, and publishing all of it has to be a stated choice
|
|
114
|
+
* (`--allow-path /`), never the result of leaving a flag off.
|
|
115
|
+
*/
|
|
116
|
+
function resolveAllowPaths(raw) {
|
|
117
|
+
let paths;
|
|
118
|
+
try {
|
|
119
|
+
paths = normalizeAllowPaths(raw ?? []);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
emit.error({
|
|
123
|
+
code: "tunnel_path_scope_invalid",
|
|
124
|
+
message: error instanceof Error ? error.message : String(error),
|
|
125
|
+
});
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
if (paths.length === 0) {
|
|
129
|
+
emit.error({
|
|
130
|
+
code: "tunnel_path_scope_empty",
|
|
131
|
+
message: "No path scope; refusing to start. Name what the tunnel shares with --allow-path <prefix> " +
|
|
132
|
+
"(repeatable), or pass --allow-path / to publish the whole upstream.",
|
|
133
|
+
});
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
return paths;
|
|
137
|
+
}
|
|
138
|
+
function samePaths(a, b) {
|
|
139
|
+
return a.length === b.length && [...a].sort().join("\n") === [...b].sort().join("\n");
|
|
140
|
+
}
|
|
141
|
+
function formatPaths(paths) {
|
|
142
|
+
return paths.length === 0 ? "(none: every path refused)" : paths.join(", ");
|
|
143
|
+
}
|
|
144
|
+
function collectPath(value, previous) {
|
|
145
|
+
return [...previous, value];
|
|
146
|
+
}
|
|
101
147
|
function gateScriptPath() {
|
|
102
148
|
return resolve(import.meta.dirname, "..", "lib", "tunnel", "gate.ts");
|
|
103
149
|
}
|
|
150
|
+
/** The gate worker's `--name`/`--port` marker, also what the stray sweep matches. */
|
|
151
|
+
const GATE_SUBCOMMAND = ["tunnel", "gate"];
|
|
104
152
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
153
|
+
* How to start the gate worker, which is a `Bun.serve` process (HTTP and
|
|
154
|
+
* WebSocket reverse proxy):
|
|
155
|
+
* 1. From a source or package checkout, `gate.ts` sits beside this module and
|
|
156
|
+
* runs directly.
|
|
157
|
+
* 2. Inside a single-file bundle there is no such file. The CLI then
|
|
158
|
+
* re-executes itself with the hidden `tunnel gate` task, which loads the
|
|
159
|
+
* gate in-process. This uses the running Bun binary, so Bun need not be
|
|
160
|
+
* on PATH.
|
|
161
|
+
* Null means neither works (a Node host with no Bun, or an unknown entry).
|
|
110
162
|
*/
|
|
111
|
-
function
|
|
112
|
-
|
|
163
|
+
export function resolveGateLaunch(i) {
|
|
164
|
+
if (i.gateScriptExists && (i.underBun || i.bunOnPath)) {
|
|
165
|
+
return {
|
|
166
|
+
command: i.underBun ? i.execPath : "bun",
|
|
167
|
+
arguments: ["run", i.gateScript],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (i.underBun && i.entryScript && i.entryScriptExists) {
|
|
171
|
+
return { command: i.execPath, arguments: [i.entryScript, ...GATE_SUBCOMMAND] };
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
113
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* How to start the log wrapper that rotates each process's output. Beside a
|
|
177
|
+
* source or package checkout the default (this package's own wrapper file)
|
|
178
|
+
* works; inside a single-file bundle the CLI re-executes itself with the hidden
|
|
179
|
+
* `tunnel log-worker` task instead.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveWorkerLaunch(i) {
|
|
182
|
+
if (i.gateScriptExists)
|
|
183
|
+
return undefined;
|
|
184
|
+
if (i.underBun && i.entryScript && i.entryScriptExists) {
|
|
185
|
+
return { command: i.execPath, arguments: [i.entryScript, "tunnel", "log-worker"] };
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
function currentLaunchInputs() {
|
|
190
|
+
const gateScript = gateScriptPath();
|
|
191
|
+
const entryScript = process.argv[1] ? resolve(process.argv[1]) : undefined;
|
|
192
|
+
const underBun = typeof process.versions.bun === "string";
|
|
193
|
+
const gateScriptExists = existsSync(gateScript);
|
|
194
|
+
return {
|
|
195
|
+
gateScript,
|
|
196
|
+
gateScriptExists,
|
|
197
|
+
underBun,
|
|
198
|
+
execPath: process.execPath,
|
|
199
|
+
entryScript,
|
|
200
|
+
entryScriptExists: entryScript ? existsSync(entryScript) : false,
|
|
201
|
+
bunOnPath: !underBun && gateScriptExists && bunOnPath(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function currentGateLaunch() {
|
|
205
|
+
return resolveGateLaunch(currentLaunchInputs());
|
|
206
|
+
}
|
|
207
|
+
function currentWorkerLaunch() {
|
|
208
|
+
return resolveWorkerLaunch(currentLaunchInputs());
|
|
209
|
+
}
|
|
210
|
+
function bunOnPath() {
|
|
211
|
+
return spawnSync("bun", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0;
|
|
212
|
+
}
|
|
213
|
+
const requiresBunMessage = () => `${resolveBinName()} tunnel requires Bun: the gate worker is a Bun.serve process. ` +
|
|
214
|
+
"Install Bun (https://bun.sh) and re-run. (Every other command runs on Node.)";
|
|
114
215
|
function tailscaleAvailable() {
|
|
115
|
-
return spawnSync("tailscale", ["version"], { stdio: "ignore" }).status === 0;
|
|
216
|
+
return spawnSync("tailscale", ["version"], { stdio: "ignore", windowsHide: true }).status === 0;
|
|
116
217
|
}
|
|
117
218
|
function sleep(ms) {
|
|
118
219
|
return new Promise((r) => setTimeout(r, ms));
|
|
119
220
|
}
|
|
120
221
|
/**
|
|
121
|
-
* Kill every process whose command line matches `pattern` (
|
|
122
|
-
* skipping our own PID and any already-killed. Returns the count
|
|
123
|
-
* as a fallback so orphaned gate/cloudflared processes get cleaned
|
|
124
|
-
* the state file was lost (which otherwise left them squatting on the
|
|
222
|
+
* Kill every process whose command line matches `pattern` (an extended regular
|
|
223
|
+
* expression), skipping our own PID and any already-killed. Returns the count
|
|
224
|
+
* killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
|
|
225
|
+
* even when the state file was lost (which otherwise left them squatting on the
|
|
226
|
+
* port).
|
|
125
227
|
*/
|
|
126
228
|
function killByPattern(pattern, alreadyKilled) {
|
|
127
|
-
const r = spawnSync("pgrep", ["-f", pattern], { encoding: "utf-8" });
|
|
128
|
-
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
129
|
-
return 0;
|
|
130
229
|
let killed = 0;
|
|
131
|
-
for (const
|
|
132
|
-
const pid = Number(line.trim());
|
|
230
|
+
for (const pid of findPidsByCommandLine(pattern)) {
|
|
133
231
|
if (!pid || pid === process.pid || alreadyKilled.has(pid))
|
|
134
232
|
continue;
|
|
135
233
|
try {
|
|
@@ -143,58 +241,41 @@ function killByPattern(pattern, alreadyKilled) {
|
|
|
143
241
|
}
|
|
144
242
|
return killed;
|
|
145
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Command-line pattern for ONE instance's gate, identified by its port. Matches
|
|
246
|
+
* both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
|
|
247
|
+
* re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
|
|
248
|
+
* guarded with `( |$)` so port 9001 doesn't match 90011.
|
|
249
|
+
*/
|
|
250
|
+
function gatePattern(gatePort) {
|
|
251
|
+
return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
|
|
252
|
+
}
|
|
146
253
|
/**
|
|
147
254
|
* Sweep stray gate + cloudflared processes for ONE instance, identified by its
|
|
148
255
|
* gate port. Both signatures are port-scoped so tearing down one tunnel never
|
|
149
256
|
* touches another:
|
|
150
|
-
* - gate:
|
|
257
|
+
* - gate: see gatePattern (the port is on the gate's argv)
|
|
151
258
|
* - cloudflared: `--url http://localhost:<port>` (order-independent, so it
|
|
152
259
|
* matches regardless of the `--protocol http2` flag we also pass).
|
|
153
|
-
* Port boundary is guarded with `( |$)` so port 9001 doesn't match 90011.
|
|
154
260
|
*/
|
|
155
261
|
function sweepStrays(gatePort, alreadyKilled) {
|
|
156
|
-
return (killByPattern(
|
|
262
|
+
return (killByPattern(gatePattern(gatePort), alreadyKilled) +
|
|
157
263
|
killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled));
|
|
158
264
|
}
|
|
159
|
-
/**
|
|
160
|
-
* Ports currently bound by a LISTEN socket (best-effort, platform-dependent).
|
|
161
|
-
*
|
|
162
|
-
* `ss` (iproute2) is Linux-only. Without the lsof fallback this returns an empty
|
|
163
|
-
* set on macOS/BSD. That does not fail loudly. It reports every port as free, so
|
|
164
|
-
* `allocateGatePort` can hand out an occupied port and the reload port-release
|
|
165
|
-
* check becomes inert.
|
|
166
|
-
*/
|
|
167
|
-
function listeningPorts() {
|
|
168
|
-
const ports = new Set();
|
|
169
|
-
const ss = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
|
|
170
|
-
if (ss.status === 0 && typeof ss.stdout === "string") {
|
|
171
|
-
for (const m of ss.stdout.matchAll(/:(\d+)\s/g))
|
|
172
|
-
ports.add(Number(m[1]));
|
|
173
|
-
return ports;
|
|
174
|
-
}
|
|
175
|
-
// Rows look like: `bun 123 user 4u IPv4 0x… 0t0 TCP 127.0.0.1:58055 (LISTEN)`.
|
|
176
|
-
// Status can be nonzero while stdout still holds usable rows (lsof reports a
|
|
177
|
-
// failure when any single process is unreadable), so judge it on the output.
|
|
178
|
-
const lsof = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf-8" });
|
|
179
|
-
if (typeof lsof.stdout === "string") {
|
|
180
|
-
for (const m of lsof.stdout.matchAll(/:(\d+) \(LISTEN\)$/gm))
|
|
181
|
-
ports.add(Number(m[1]));
|
|
182
|
-
}
|
|
183
|
-
return ports;
|
|
184
|
-
}
|
|
185
265
|
/**
|
|
186
266
|
* Pick a gate port for a new instance. An explicit `--gate-port` is honored
|
|
187
267
|
* (and rejected if it's already taken); otherwise scan upward from 9001 for the
|
|
188
268
|
* first port that's neither held by a live instance nor currently listening.
|
|
189
269
|
*/
|
|
190
|
-
function allocateGatePort(preferred) {
|
|
270
|
+
async function allocateGatePort(preferred) {
|
|
191
271
|
const used = new Set(listStates()
|
|
192
272
|
.filter((s) => isProcessAlive(s.gate_pid))
|
|
193
273
|
.map((s) => s.gate_port));
|
|
274
|
+
// No discovery tool (null) is not "every port free": probe candidates by connecting.
|
|
194
275
|
const listening = listeningPorts();
|
|
195
|
-
const taken = (p) => used.has(p) || listening.has(p);
|
|
276
|
+
const taken = async (p) => used.has(p) || (listening ? listening.has(p) : await connectProbe(p));
|
|
196
277
|
if (preferred !== undefined) {
|
|
197
|
-
if (taken(preferred)) {
|
|
278
|
+
if (await taken(preferred)) {
|
|
198
279
|
emit.error({
|
|
199
280
|
code: "tunnel_port_taken",
|
|
200
281
|
message: `Gate port ${preferred} is already in use. Omit --gate-port to auto-allocate, or pick a free one.`,
|
|
@@ -204,7 +285,7 @@ function allocateGatePort(preferred) {
|
|
|
204
285
|
return preferred;
|
|
205
286
|
}
|
|
206
287
|
for (let p = DEFAULT_GATE_PORT; p <= MAX_GATE_PORT; p++) {
|
|
207
|
-
if (!taken(p))
|
|
288
|
+
if (!(await taken(p)))
|
|
208
289
|
return p;
|
|
209
290
|
}
|
|
210
291
|
emit.error({
|
|
@@ -352,13 +433,14 @@ function tunnelIsAlive(state) {
|
|
|
352
433
|
* is passed as an env var, which the gate snapshots at module load and never
|
|
353
434
|
* re-reads — that snapshot is precisely why `reload` has to exist.
|
|
354
435
|
*/
|
|
355
|
-
function spawnGate(o) {
|
|
436
|
+
function spawnGate(o, launch) {
|
|
356
437
|
// `--name`/`--port` on argv mirror the env vars; they're what makes the gate
|
|
357
|
-
// process distinguishable per-instance in
|
|
438
|
+
// process distinguishable per-instance in a command-line search (see sweepStrays).
|
|
358
439
|
const gateProc = spawnRotatingProcess({
|
|
359
440
|
path: o.gateLogPath,
|
|
360
|
-
command:
|
|
361
|
-
arguments: [
|
|
441
|
+
command: launch.command,
|
|
442
|
+
arguments: [...launch.arguments, "--name", o.name, "--port", String(o.gatePort)],
|
|
443
|
+
worker: currentWorkerLaunch(),
|
|
362
444
|
env: {
|
|
363
445
|
...process.env,
|
|
364
446
|
HARNERY_TUNNEL_ALLOW: o.allowedIps.join(","),
|
|
@@ -366,18 +448,16 @@ function spawnGate(o) {
|
|
|
366
448
|
HARNERY_TUNNEL_TARGET: o.target,
|
|
367
449
|
HARNERY_TUNNEL_VHOST: o.vhost,
|
|
368
450
|
HARNERY_TUNNEL_PORT: String(o.gatePort),
|
|
451
|
+
[ALLOW_PATHS_ENV]: o.allowPaths.join(","),
|
|
369
452
|
},
|
|
370
453
|
});
|
|
371
454
|
gateProc.unref();
|
|
372
455
|
return gateProc;
|
|
373
456
|
}
|
|
374
457
|
async function up(opts) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
message: "harn tunnel requires Bun: the gate worker is a Bun.serve process. " +
|
|
379
|
-
"Install Bun (https://bun.sh) and re-run. (Every other harn command runs on Node.)",
|
|
380
|
-
});
|
|
458
|
+
const launch = currentGateLaunch();
|
|
459
|
+
if (!launch) {
|
|
460
|
+
emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
|
|
381
461
|
process.exit(1);
|
|
382
462
|
}
|
|
383
463
|
const name = resolveName(opts.name);
|
|
@@ -389,6 +469,7 @@ async function up(opts) {
|
|
|
389
469
|
const tailscaleMode = provider === "tailscale" ? resolveTailscaleMode(opts.visibility) : undefined;
|
|
390
470
|
const tailscalePath = provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
|
|
391
471
|
const tailscaleHttpsPort = provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
|
|
472
|
+
const allowPaths = resolveAllowPaths(opts.allowPath);
|
|
392
473
|
const existing = readState(name);
|
|
393
474
|
if (existing && tunnelIsAlive(existing)) {
|
|
394
475
|
if (existing.provider !== provider) {
|
|
@@ -398,16 +479,25 @@ async function up(opts) {
|
|
|
398
479
|
});
|
|
399
480
|
process.exit(1);
|
|
400
481
|
}
|
|
482
|
+
if (!samePaths(existing.allow_paths, allowPaths)) {
|
|
483
|
+
emit.error({
|
|
484
|
+
code: "tunnel_instance_in_use",
|
|
485
|
+
message: `Tunnel [${name}] is already up sharing ${formatPaths(existing.allow_paths)}. ` +
|
|
486
|
+
`Stop it before starting it with ${formatPaths(allowPaths)}.`,
|
|
487
|
+
});
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
401
490
|
emit.text(`Already up [${name}]: ${existing.url}\n`);
|
|
402
491
|
emit.text(` Provider: ${existing.provider}\n`);
|
|
403
492
|
emit.text(` Forwarding: ${existing.target} (Host: ${existing.vhost})\n`);
|
|
493
|
+
emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
|
|
404
494
|
return;
|
|
405
495
|
}
|
|
406
496
|
if (existing)
|
|
407
497
|
downOne(name, new Set());
|
|
408
498
|
// Allocate the gate port (after clearing dead state so its old port frees up
|
|
409
499
|
// for reuse). Explicit --gate-port is validated; otherwise auto-scan.
|
|
410
|
-
const gatePort = allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
|
|
500
|
+
const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
|
|
411
501
|
// Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
|
|
412
502
|
// a prior crashed or state-cleared run so the gate port is free before we bind.
|
|
413
503
|
if (sweepStrays(gatePort, new Set()))
|
|
@@ -416,11 +506,23 @@ async function up(opts) {
|
|
|
416
506
|
if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
|
|
417
507
|
emit.error({
|
|
418
508
|
code: "tunnel_allowlist_empty",
|
|
419
|
-
message:
|
|
509
|
+
message: `Allowlist is empty; refusing to start. Add an IP first: ${resolveBinName()} tunnel allow add <ip> (or --current)`,
|
|
420
510
|
});
|
|
421
511
|
process.exit(1);
|
|
422
512
|
}
|
|
423
|
-
|
|
513
|
+
let cloudflaredBin = null;
|
|
514
|
+
if (provider === "cloudflare") {
|
|
515
|
+
try {
|
|
516
|
+
cloudflaredBin = ensureCloudflared();
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
emit.error({
|
|
520
|
+
code: "tunnel_cloudflared_missing",
|
|
521
|
+
message: error instanceof Error ? error.message : String(error),
|
|
522
|
+
});
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
424
526
|
if (provider === "tailscale" && !tailscaleAvailable()) {
|
|
425
527
|
emit.error({
|
|
426
528
|
code: "tunnel_tailscale_missing",
|
|
@@ -438,10 +540,20 @@ async function up(opts) {
|
|
|
438
540
|
vhost,
|
|
439
541
|
provider,
|
|
440
542
|
allowedIps: cfg.allowed_ips,
|
|
543
|
+
allowPaths,
|
|
441
544
|
gateLogPath,
|
|
442
|
-
});
|
|
545
|
+
}, launch);
|
|
443
546
|
await sleep(800);
|
|
444
|
-
|
|
547
|
+
// A host CLI that re-executes itself takes longer to load than a bare script,
|
|
548
|
+
// so wait for the gate to actually listen before the provider forwards to it.
|
|
549
|
+
if (!isProcessAlive(gateProc.pid) || !(await waitForPortBound(gatePort, 15_000))) {
|
|
550
|
+
try {
|
|
551
|
+
process.kill(gateProc.pid);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
/* already dead */
|
|
555
|
+
}
|
|
556
|
+
sweepStrays(gatePort, new Set());
|
|
445
557
|
emit.error({
|
|
446
558
|
code: "tunnel_gate_failed",
|
|
447
559
|
message: `Gate failed to start. Check log: ${gateLogPath}`,
|
|
@@ -461,6 +573,7 @@ async function up(opts) {
|
|
|
461
573
|
path: providerLogPath,
|
|
462
574
|
command: cloudflaredBin,
|
|
463
575
|
arguments: ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`],
|
|
576
|
+
worker: currentWorkerLaunch(),
|
|
464
577
|
});
|
|
465
578
|
cfdProc.unref();
|
|
466
579
|
cloudflaredPid = cfdProc.pid;
|
|
@@ -507,16 +620,20 @@ async function up(opts) {
|
|
|
507
620
|
target,
|
|
508
621
|
vhost,
|
|
509
622
|
gate_port: gatePort,
|
|
623
|
+
allow_paths: allowPaths,
|
|
510
624
|
tailscale_mode: tailscaleMode,
|
|
511
625
|
tailscale_path: tailscalePath,
|
|
512
626
|
tailscale_https_port: tailscaleHttpsPort,
|
|
513
627
|
};
|
|
514
628
|
writeState(state);
|
|
515
|
-
|
|
629
|
+
recordTunnelServer(state);
|
|
630
|
+
const bin = resolveBinName();
|
|
631
|
+
const stopHint = name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
|
|
516
632
|
emit.text(`\n Instance: ${name}\n`);
|
|
517
633
|
emit.text(` Provider: ${provider}${tailscaleMode ? ` (${tailscaleMode})` : ""}\n`);
|
|
518
634
|
emit.text(` URL: ${url}\n\n`);
|
|
519
635
|
emit.text(` Forwarding: ${target} (Host: ${vhost})\n`);
|
|
636
|
+
emit.text(` Paths: ${formatPaths(allowPaths)}\n`);
|
|
520
637
|
emit.text(` Gate port: ${gatePort}\n`);
|
|
521
638
|
if (provider === "cloudflare") {
|
|
522
639
|
emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n\n`);
|
|
@@ -528,13 +645,24 @@ async function up(opts) {
|
|
|
528
645
|
: " Visibility: tailnet only via Tailscale Serve\n\n");
|
|
529
646
|
}
|
|
530
647
|
if (!registered) {
|
|
531
|
-
emit.text(` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} &&
|
|
648
|
+
emit.text(` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} && ${bin} tunnel up\n\n`);
|
|
532
649
|
}
|
|
533
650
|
emit.text(` Stop: ${stopHint}\n`);
|
|
534
|
-
emit.text(
|
|
651
|
+
emit.text(` Status: ${bin} tunnel status\n`);
|
|
652
|
+
}
|
|
653
|
+
/** The server registry lists tunnels beside every other local server. */
|
|
654
|
+
function registryRoot() {
|
|
655
|
+
return resolveCoordRoot() ?? process.cwd();
|
|
656
|
+
}
|
|
657
|
+
function recordTunnelServer(state) {
|
|
658
|
+
const coordRoot = registryRoot();
|
|
659
|
+
tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
|
|
535
660
|
}
|
|
536
661
|
/** Tear down a single instance by name. Returns the number of processes killed. */
|
|
537
|
-
function
|
|
662
|
+
function portHeldByOtherLiveInstance(port, name) {
|
|
663
|
+
return listStates().some((s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid));
|
|
664
|
+
}
|
|
665
|
+
export function downOne(name, killed) {
|
|
538
666
|
const before = killed.size;
|
|
539
667
|
const state = readState(name);
|
|
540
668
|
if (state) {
|
|
@@ -555,9 +683,14 @@ function downOne(name, killed) {
|
|
|
555
683
|
}
|
|
556
684
|
}
|
|
557
685
|
// Fallback: sweep orphans on this instance's gate port, even when state was
|
|
558
|
-
// lost; they'd otherwise squat on the port and break the next `up`.
|
|
559
|
-
|
|
686
|
+
// lost; they'd otherwise squat on the port and break the next `up`. Skip it
|
|
687
|
+
// when another live instance now owns that port: a stale record's port is
|
|
688
|
+
// reused after a reboot, and sweeping it would kill the live tunnel there.
|
|
689
|
+
const port = state?.gate_port ?? DEFAULT_GATE_PORT;
|
|
690
|
+
if (!portHeldByOtherLiveInstance(port, name))
|
|
691
|
+
sweepStrays(port, killed);
|
|
560
692
|
clearState(name);
|
|
693
|
+
unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
|
|
561
694
|
return killed.size - before;
|
|
562
695
|
}
|
|
563
696
|
function down(opts) {
|
|
@@ -579,7 +712,7 @@ function down(opts) {
|
|
|
579
712
|
if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
|
|
580
713
|
const others = listStates();
|
|
581
714
|
if (others.length > 0) {
|
|
582
|
-
emit.text(`No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse
|
|
715
|
+
emit.text(`No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`${resolveBinName()} tunnel down --name <name>\` or \`${resolveBinName()} tunnel down --all\`.\n`);
|
|
583
716
|
return;
|
|
584
717
|
}
|
|
585
718
|
}
|
|
@@ -592,21 +725,21 @@ function down(opts) {
|
|
|
592
725
|
async function waitForPortFree(port, timeoutMs) {
|
|
593
726
|
const deadline = Date.now() + timeoutMs;
|
|
594
727
|
while (Date.now() < deadline) {
|
|
595
|
-
if (!
|
|
728
|
+
if (!(await portListening(port)))
|
|
596
729
|
return true;
|
|
597
730
|
await sleep(100);
|
|
598
731
|
}
|
|
599
|
-
return !
|
|
732
|
+
return !(await portListening(port));
|
|
600
733
|
}
|
|
601
734
|
/** Poll until `port` has a LISTEN socket (or the deadline passes). */
|
|
602
735
|
async function waitForPortBound(port, timeoutMs) {
|
|
603
736
|
const deadline = Date.now() + timeoutMs;
|
|
604
737
|
while (Date.now() < deadline) {
|
|
605
|
-
if (
|
|
738
|
+
if (await portListening(port))
|
|
606
739
|
return true;
|
|
607
740
|
await sleep(100);
|
|
608
741
|
}
|
|
609
|
-
return
|
|
742
|
+
return portListening(port);
|
|
610
743
|
}
|
|
611
744
|
/**
|
|
612
745
|
* Restart ONE instance's gate in place, deliberately leaving the provider
|
|
@@ -630,8 +763,20 @@ export async function reloadOne(state) {
|
|
|
630
763
|
`Reloading the gate can't bring it back — run \`${bin} tunnel up --name ${name}\`.`,
|
|
631
764
|
};
|
|
632
765
|
}
|
|
633
|
-
|
|
634
|
-
|
|
766
|
+
const launch = currentGateLaunch();
|
|
767
|
+
if (!launch) {
|
|
768
|
+
return {
|
|
769
|
+
ok: false,
|
|
770
|
+
message: `[${name}] Bun is not available, so the gate can't be respawned.`,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
if (state.allow_paths.length === 0) {
|
|
774
|
+
return {
|
|
775
|
+
ok: false,
|
|
776
|
+
message: `[${name}] has no recorded path scope, so a reloaded gate would refuse every request. ` +
|
|
777
|
+
`Restart it with \`${bin} tunnel down --name ${name}\` and ` +
|
|
778
|
+
`\`${bin} tunnel up --name ${name} --allow-path <prefix>\`.`,
|
|
779
|
+
};
|
|
635
780
|
}
|
|
636
781
|
// Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
|
|
637
782
|
// matches `--url http://localhost:<port>`, which is the provider process we
|
|
@@ -646,7 +791,7 @@ export async function reloadOne(state) {
|
|
|
646
791
|
/* race: already gone */
|
|
647
792
|
}
|
|
648
793
|
}
|
|
649
|
-
killByPattern(
|
|
794
|
+
killByPattern(gatePattern(gatePort), killed);
|
|
650
795
|
if (!(await waitForPortFree(gatePort, 5_000))) {
|
|
651
796
|
return {
|
|
652
797
|
ok: false,
|
|
@@ -662,13 +807,14 @@ export async function reloadOne(state) {
|
|
|
662
807
|
vhost: state.vhost,
|
|
663
808
|
provider: state.provider,
|
|
664
809
|
allowedIps: cfg.allowed_ips,
|
|
810
|
+
allowPaths: state.allow_paths,
|
|
665
811
|
gateLogPath: tunnelLogDestinations(name, state.provider).gate,
|
|
666
|
-
});
|
|
812
|
+
}, launch);
|
|
667
813
|
await sleep(800);
|
|
668
814
|
const pid = gateProc.pid;
|
|
669
815
|
if (typeof pid !== "number" ||
|
|
670
816
|
!isProcessAlive(pid) ||
|
|
671
|
-
!(await waitForPortBound(gatePort,
|
|
817
|
+
!(await waitForPortBound(gatePort, 15_000))) {
|
|
672
818
|
return {
|
|
673
819
|
ok: false,
|
|
674
820
|
message: `[${name}] the gate did not come back on port ${gatePort}. ` +
|
|
@@ -676,6 +822,7 @@ export async function reloadOne(state) {
|
|
|
676
822
|
};
|
|
677
823
|
}
|
|
678
824
|
writeState({ ...state, gate_pid: pid });
|
|
825
|
+
recordTunnelServer({ ...state, gate_pid: pid });
|
|
679
826
|
const allowNote = state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
|
|
680
827
|
return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
|
|
681
828
|
}
|
|
@@ -745,6 +892,7 @@ function statusDetail(state) {
|
|
|
745
892
|
emit.text(` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`);
|
|
746
893
|
emit.text(` URL: ${state.url}\n`);
|
|
747
894
|
emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
|
|
895
|
+
emit.text(` Paths: ${formatPaths(state.allow_paths)}\n`);
|
|
748
896
|
emit.text(` Gate port: ${state.gate_port}\n`);
|
|
749
897
|
if (state.provider === "cloudflare") {
|
|
750
898
|
emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n`);
|
|
@@ -786,6 +934,7 @@ function status(opts) {
|
|
|
786
934
|
state: instanceState(s),
|
|
787
935
|
url: s.url,
|
|
788
936
|
fwd: `${s.target} (${s.vhost})`,
|
|
937
|
+
paths: formatPaths(s.allow_paths),
|
|
789
938
|
port: String(s.gate_port),
|
|
790
939
|
up: fmtUptime(s.started_at),
|
|
791
940
|
}));
|
|
@@ -795,12 +944,13 @@ function status(opts) {
|
|
|
795
944
|
state: 5,
|
|
796
945
|
url: Math.max(3, ...rows.map((r) => r.url.length)),
|
|
797
946
|
fwd: Math.max(10, ...rows.map((r) => r.fwd.length)),
|
|
947
|
+
paths: Math.max(5, ...rows.map((r) => r.paths.length)),
|
|
798
948
|
port: 4,
|
|
799
949
|
};
|
|
800
950
|
const pad = (s, n) => s.padEnd(n);
|
|
801
|
-
emit.text(`${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PORT", w.port)} UPTIME\n`);
|
|
951
|
+
emit.text(`${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PATHS", w.paths)} ${pad("PORT", w.port)} UPTIME\n`);
|
|
802
952
|
for (const r of rows) {
|
|
803
|
-
emit.text(`${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.port, w.port)} ${r.up}\n`);
|
|
953
|
+
emit.text(`${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.paths, w.paths)} ${pad(r.port, w.port)} ${r.up}\n`);
|
|
804
954
|
}
|
|
805
955
|
}
|
|
806
956
|
function logs(opts) {
|
|
@@ -824,9 +974,13 @@ function allowList() {
|
|
|
824
974
|
emit.text("(empty)\n");
|
|
825
975
|
return;
|
|
826
976
|
}
|
|
827
|
-
|
|
828
|
-
|
|
977
|
+
const automatic = cfg.auto_allowed ?? [];
|
|
978
|
+
for (const ip of cfg.allowed_ips) {
|
|
979
|
+
emit.text(`${ip}${sameEntryIn(automatic, ip) ? " (automatic)" : ""}\n`);
|
|
980
|
+
}
|
|
829
981
|
}
|
|
982
|
+
const canonicalEntry = (entry) => normalizeAllowEntry(entry) ?? entry;
|
|
983
|
+
const sameEntryIn = (list, entry) => list.some((e) => canonicalEntry(e) === canonicalEntry(entry));
|
|
830
984
|
/**
|
|
831
985
|
* Trailer for `allow add`/`allow rm`. Each gate snapshots the allowlist at
|
|
832
986
|
* spawn, so a config edit alone changes nothing until the gates restart.
|
|
@@ -842,29 +996,122 @@ function allowChangedHint() {
|
|
|
842
996
|
`(${live.map((s) => s.name).join(", ")}):\n` +
|
|
843
997
|
` ${resolveBinName()} tunnel reload --all\n`);
|
|
844
998
|
}
|
|
845
|
-
function allowAdd(
|
|
999
|
+
async function allowAdd(entry, opts = {}) {
|
|
1000
|
+
if (opts.current) {
|
|
1001
|
+
if (entry) {
|
|
1002
|
+
emit.error({
|
|
1003
|
+
code: "tunnel_allow_current_with_entry",
|
|
1004
|
+
message: "Pass either an address or --current, not both.",
|
|
1005
|
+
});
|
|
1006
|
+
process.exit(1);
|
|
1007
|
+
}
|
|
1008
|
+
const result = await refreshCurrentAddress({ emit, reload: true });
|
|
1009
|
+
if (!result.ok)
|
|
1010
|
+
process.exit(1);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (!entry) {
|
|
1014
|
+
emit.error({
|
|
1015
|
+
code: "tunnel_allow_entry_missing",
|
|
1016
|
+
message: `Name an address or range to allow, or use --current: ${resolveBinName()} tunnel allow add <ip|cidr>`,
|
|
1017
|
+
});
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
}
|
|
1020
|
+
const canonical = normalizeAllowEntry(entry);
|
|
1021
|
+
if (!canonical) {
|
|
1022
|
+
emit.error({
|
|
1023
|
+
code: "tunnel_allow_entry_invalid",
|
|
1024
|
+
message: `"${entry}" is not an IP address or CIDR range. ` +
|
|
1025
|
+
"Examples: 203.0.113.8, 203.0.113.0/24, 2601:db8:1:2::/64.",
|
|
1026
|
+
});
|
|
1027
|
+
process.exit(1);
|
|
1028
|
+
}
|
|
846
1029
|
const cfg = readConfig();
|
|
847
|
-
if (cfg.allowed_ips
|
|
848
|
-
|
|
1030
|
+
if (sameEntryIn(cfg.allowed_ips, canonical)) {
|
|
1031
|
+
// Adding by hand an entry the refresh owns adopts it: later refreshes keep it.
|
|
1032
|
+
if (cfg.auto_allowed && sameEntryIn(cfg.auto_allowed, canonical)) {
|
|
1033
|
+
cfg.auto_allowed = cfg.auto_allowed.filter((e) => canonicalEntry(e) !== canonical);
|
|
1034
|
+
writeConfig(cfg);
|
|
1035
|
+
emit.text(`${canonical} is now a manual entry; automatic refreshes will keep it.\n`);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
emit.text(`${canonical} already in allowlist.\n`);
|
|
849
1039
|
return;
|
|
850
1040
|
}
|
|
851
|
-
cfg.allowed_ips.push(
|
|
1041
|
+
cfg.allowed_ips.push(canonical);
|
|
852
1042
|
writeConfig(cfg);
|
|
853
|
-
emit.text(`Added ${
|
|
1043
|
+
emit.text(`Added ${canonical}.\n`);
|
|
854
1044
|
allowChangedHint();
|
|
855
1045
|
}
|
|
856
|
-
function allowRm(
|
|
1046
|
+
function allowRm(entry) {
|
|
857
1047
|
const cfg = readConfig();
|
|
858
|
-
const
|
|
1048
|
+
const wanted = canonicalEntry(entry);
|
|
1049
|
+
const idx = cfg.allowed_ips.findIndex((e) => e === entry || canonicalEntry(e) === wanted);
|
|
859
1050
|
if (idx === -1) {
|
|
860
|
-
emit.text(`${
|
|
1051
|
+
emit.text(`${entry} not in allowlist.\n`);
|
|
861
1052
|
return;
|
|
862
1053
|
}
|
|
863
|
-
cfg.allowed_ips.splice(idx, 1);
|
|
1054
|
+
const [removed] = cfg.allowed_ips.splice(idx, 1);
|
|
1055
|
+
if (cfg.auto_allowed) {
|
|
1056
|
+
cfg.auto_allowed = cfg.auto_allowed.filter((e) => canonicalEntry(e) !== canonicalEntry(removed));
|
|
1057
|
+
}
|
|
864
1058
|
writeConfig(cfg);
|
|
865
|
-
emit.text(`Removed ${
|
|
1059
|
+
emit.text(`Removed ${removed}.\n`);
|
|
866
1060
|
allowChangedHint();
|
|
867
1061
|
}
|
|
1062
|
+
/**
|
|
1063
|
+
* `tunnel allow add --current`: allow this machine's own public addresses, as
|
|
1064
|
+
* Cloudflare sees them, and drop the ones allowed by the previous refresh.
|
|
1065
|
+
* Exported so a host CLI can run it before `tunnel up` and from its own sync.
|
|
1066
|
+
*/
|
|
1067
|
+
export async function refreshCurrentAddress(options) {
|
|
1068
|
+
if (options.emit)
|
|
1069
|
+
emit = options.emit;
|
|
1070
|
+
const detected = await (options.detect ?? detectPublicAddresses)(options.detectOptions);
|
|
1071
|
+
const cfg = readConfig();
|
|
1072
|
+
const plan = planCurrentAddresses(cfg, detected);
|
|
1073
|
+
const noAddress = !detected.v4 && !detected.v6;
|
|
1074
|
+
// Quiet runs (a host's sync) speak only when something changed or nothing
|
|
1075
|
+
// could be detected; "no IPv6 on this network" is not news every time.
|
|
1076
|
+
if (!options.quiet || noAddress || plan.changed) {
|
|
1077
|
+
for (const warning of plan.warnings)
|
|
1078
|
+
emit.text(` ⚠ ${warning}\n`);
|
|
1079
|
+
}
|
|
1080
|
+
const result = {
|
|
1081
|
+
ok: true,
|
|
1082
|
+
changed: plan.changed,
|
|
1083
|
+
added: plan.added,
|
|
1084
|
+
removed: plan.removed,
|
|
1085
|
+
warnings: plan.warnings,
|
|
1086
|
+
};
|
|
1087
|
+
if (noAddress)
|
|
1088
|
+
return result;
|
|
1089
|
+
writeConfig(plan.config);
|
|
1090
|
+
if (!options.quiet || plan.changed) {
|
|
1091
|
+
const found = [detected.v4, detected.v6].filter(Boolean).join(", ");
|
|
1092
|
+
emit.text(`This machine reaches Cloudflare as ${found}.\n`);
|
|
1093
|
+
emit.text(plan.changed
|
|
1094
|
+
? `Allowlist updated${plan.added.length ? `: added ${plan.added.join(", ")}` : ""}${plan.removed.length ? `; removed ${plan.removed.join(", ")}` : ""}.\n`
|
|
1095
|
+
: "Allowlist already covers this machine.\n");
|
|
1096
|
+
}
|
|
1097
|
+
if (!plan.changed || !options.reload)
|
|
1098
|
+
return result;
|
|
1099
|
+
const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
|
|
1100
|
+
for (const s of live) {
|
|
1101
|
+
const r = await reloadOne(s);
|
|
1102
|
+
emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
|
|
1103
|
+
if (!r.ok)
|
|
1104
|
+
result.ok = false;
|
|
1105
|
+
}
|
|
1106
|
+
return result;
|
|
1107
|
+
}
|
|
1108
|
+
/** Hidden task: run the gate worker in this process (the self-launch form of the gate). */
|
|
1109
|
+
async function runGateInProcess() {
|
|
1110
|
+
await import("../lib/tunnel/gate.js");
|
|
1111
|
+
// The gate's server keeps the process alive. Never resolve, so a host's
|
|
1112
|
+
// post-command exit or output finalization cannot end the worker.
|
|
1113
|
+
await new Promise(() => { });
|
|
1114
|
+
}
|
|
868
1115
|
let emit;
|
|
869
1116
|
let context;
|
|
870
1117
|
export function registerTunnelCommand(program, emitParam, contextParam) {
|
|
@@ -886,6 +1133,8 @@ export function registerTunnelCommand(program, emitParam, contextParam) {
|
|
|
886
1133
|
.option("--visibility <visibility>", "Tailscale only: tailnet (Serve, default) or public (Funnel)", "tailnet")
|
|
887
1134
|
.option("--path <path>", "Tailscale only: URL path mount (default: / for default, /<name> for named instances)")
|
|
888
1135
|
.option("--https-port <port>", "Tailscale only: HTTPS listen port (default: 443)", "443")
|
|
1136
|
+
.option("--allow-path <prefix>", "URL path prefix the tunnel shares (repeatable, required). Every other path is refused; " +
|
|
1137
|
+
"pass / to publish the whole upstream.", collectPath, [])
|
|
889
1138
|
.action(up);
|
|
890
1139
|
cmd
|
|
891
1140
|
.command("down")
|
|
@@ -914,9 +1163,29 @@ export function registerTunnelCommand(program, emitParam, contextParam) {
|
|
|
914
1163
|
.action(logs);
|
|
915
1164
|
const allow = cmd
|
|
916
1165
|
.command("allow")
|
|
917
|
-
.description("Manage the Cloudflare CF-Connecting-IP allowlist")
|
|
1166
|
+
.description("Manage the Cloudflare CF-Connecting-IP allowlist (addresses and CIDR ranges, IPv4 and IPv6)")
|
|
918
1167
|
.action(allowList);
|
|
919
|
-
allow
|
|
920
|
-
|
|
921
|
-
|
|
1168
|
+
allow
|
|
1169
|
+
.command("add [entry]")
|
|
1170
|
+
.description("Add an IP address or CIDR range (203.0.113.0/24, 2601:db8:1:2::/64), or --current for this machine")
|
|
1171
|
+
.option("--current", "allow this machine's public addresses as Cloudflare sees them (IPv4 /32 and IPv6 /64), " +
|
|
1172
|
+
"replace the ones the previous --current added, and reload running gates")
|
|
1173
|
+
.action(allowAdd);
|
|
1174
|
+
allow
|
|
1175
|
+
.command("rm <entry>")
|
|
1176
|
+
.description("Remove an address or range from the allowlist")
|
|
1177
|
+
.action(allowRm);
|
|
1178
|
+
allow.command("list").description("List allowed entries (default action)").action(allowList);
|
|
1179
|
+
cmd
|
|
1180
|
+
.command("log-worker <specification>", { hidden: true })
|
|
1181
|
+
.description("Internal: run the output-rotating wrapper for one tunnel process")
|
|
1182
|
+
.action(async (specification) => {
|
|
1183
|
+
await runProcessLogWorker(specification);
|
|
1184
|
+
});
|
|
1185
|
+
cmd
|
|
1186
|
+
.command("gate", { hidden: true })
|
|
1187
|
+
.description("Internal: run the gate worker in this process")
|
|
1188
|
+
.option("--name <name>", "instance name")
|
|
1189
|
+
.option("--port <port>", "port to listen on")
|
|
1190
|
+
.action(runGateInProcess);
|
|
922
1191
|
}
|