ssh_tunnel_proxy 2.1.1 → 3.0.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.
Files changed (109) hide show
  1. package/LICENSE +19 -3
  2. package/MIGRATION.md +227 -0
  3. package/README.md +360 -212
  4. package/build/bridge.d.ts +14 -0
  5. package/build/bridge.d.ts.map +1 -0
  6. package/build/bridge.js +12 -0
  7. package/build/bridge.js.map +1 -0
  8. package/build/connection.d.ts +60 -0
  9. package/build/connection.d.ts.map +1 -0
  10. package/build/connection.js +198 -0
  11. package/build/connection.js.map +1 -0
  12. package/build/credentials.d.ts +24 -0
  13. package/build/credentials.d.ts.map +1 -0
  14. package/build/credentials.js +102 -0
  15. package/build/credentials.js.map +1 -0
  16. package/build/errors.d.ts +19 -0
  17. package/build/errors.d.ts.map +1 -0
  18. package/build/errors.js +64 -0
  19. package/build/errors.js.map +1 -0
  20. package/build/forward-spec.d.ts +34 -0
  21. package/build/forward-spec.d.ts.map +1 -0
  22. package/build/forward-spec.js +175 -0
  23. package/build/forward-spec.js.map +1 -0
  24. package/build/index.d.ts +19 -0
  25. package/build/index.d.ts.map +1 -0
  26. package/build/index.js +71 -0
  27. package/build/index.js.map +1 -0
  28. package/build/legacy.d.ts +70 -0
  29. package/build/legacy.d.ts.map +1 -0
  30. package/build/legacy.js +188 -0
  31. package/build/legacy.js.map +1 -0
  32. package/build/local-forward.d.ts +31 -0
  33. package/build/local-forward.d.ts.map +1 -0
  34. package/build/local-forward.js +231 -0
  35. package/build/local-forward.js.map +1 -0
  36. package/build/model.d.ts +126 -0
  37. package/build/model.d.ts.map +1 -0
  38. package/build/model.js +55 -0
  39. package/build/model.js.map +1 -0
  40. package/build/port-policy.d.ts +43 -0
  41. package/build/port-policy.d.ts.map +1 -0
  42. package/build/port-policy.js +138 -0
  43. package/build/port-policy.js.map +1 -0
  44. package/build/remote-forward.d.ts +40 -0
  45. package/build/remote-forward.d.ts.map +1 -0
  46. package/build/remote-forward.js +226 -0
  47. package/build/remote-forward.js.map +1 -0
  48. package/build/tunnel.d.ts +121 -0
  49. package/build/tunnel.d.ts.map +1 -0
  50. package/build/tunnel.js +599 -0
  51. package/build/tunnel.js.map +1 -0
  52. package/examples/config.example.json +86 -0
  53. package/examples/setup_ssh.sh +12 -0
  54. package/examples/tunnel-cli.js +461 -0
  55. package/examples/tunnel-worker.js +98 -0
  56. package/examples/zrok-termux.ts +147 -0
  57. package/package.json +43 -53
  58. package/proto/sshtunnel/v1/tunnel.proto +343 -0
  59. package/dist/TestMessage.d.ts +0 -13
  60. package/dist/TestMessage.js +0 -32
  61. package/dist/TestMessage.js.map +0 -1
  62. package/dist/cjs/TestMessage.d.ts +0 -13
  63. package/dist/cjs/TestMessage.js +0 -32
  64. package/dist/cjs/TestMessage.js.map +0 -1
  65. package/dist/cjs/index.d.ts +0 -73
  66. package/dist/cjs/index.js +0 -621
  67. package/dist/cjs/index.js.map +0 -1
  68. package/dist/cjs/keypair_storage.d.ts +0 -12
  69. package/dist/cjs/keypair_storage.js +0 -143
  70. package/dist/cjs/keypair_storage.js.map +0 -1
  71. package/dist/cjs/ngrok_service.d.ts +0 -11
  72. package/dist/cjs/ngrok_service.js +0 -40
  73. package/dist/cjs/ngrok_service.js.map +0 -1
  74. package/dist/cjs/ssh2-node.d.ts +0 -1
  75. package/dist/cjs/ssh2-node.js +0 -211
  76. package/dist/cjs/ssh2-node.js.map +0 -1
  77. package/dist/esm/TestMessage.d.ts +0 -13
  78. package/dist/esm/TestMessage.js +0 -29
  79. package/dist/esm/TestMessage.js.map +0 -1
  80. package/dist/esm/index.d.ts +0 -73
  81. package/dist/esm/index.js +0 -616
  82. package/dist/esm/index.js.map +0 -1
  83. package/dist/esm/keypair_storage.d.ts +0 -12
  84. package/dist/esm/keypair_storage.js +0 -117
  85. package/dist/esm/keypair_storage.js.map +0 -1
  86. package/dist/esm/ngrok_service.d.ts +0 -11
  87. package/dist/esm/ngrok_service.js +0 -37
  88. package/dist/esm/ngrok_service.js.map +0 -1
  89. package/dist/esm/ssh2-node.d.ts +0 -1
  90. package/dist/esm/ssh2-node.js +0 -184
  91. package/dist/esm/ssh2-node.js.map +0 -1
  92. package/dist/index.d.ts +0 -73
  93. package/dist/index.js +0 -620
  94. package/dist/index.js.map +0 -1
  95. package/dist/keypair_storage.d.ts +0 -12
  96. package/dist/keypair_storage.js +0 -120
  97. package/dist/keypair_storage.js.map +0 -1
  98. package/dist/ngrok_service.d.ts +0 -11
  99. package/dist/ngrok_service.js +0 -40
  100. package/dist/ngrok_service.js.map +0 -1
  101. package/dist/ssh2-node.d.ts +0 -1
  102. package/dist/ssh2-node.js +0 -188
  103. package/dist/ssh2-node.js.map +0 -1
  104. package/dist/types/TestMessage.d.ts +0 -13
  105. package/dist/types/index.d.ts +0 -73
  106. package/dist/types/keypair_storage.d.ts +0 -12
  107. package/dist/types/ngrok_service.d.ts +0 -11
  108. package/dist/types/ssh2-node.d.ts +0 -1
  109. package/ssh2-node +0 -2
@@ -0,0 +1,86 @@
1
+ [
2
+ {
3
+ "hostname": "home",
4
+ "username": "user",
5
+ "host": "127.0.0.1",
6
+ "port": "9191",
7
+ "private_key_filename": "~/.ssh/id_ed25519",
8
+ "proxy_ports": ["8123:127.0.0.1:8123", "8080:127.0.0.1:8080"],
9
+ "whitelist": { "22": true, "80": true, "443": true }
10
+ },
11
+
12
+ {
13
+ "name": "home-v2",
14
+ "transport": {
15
+ "endpoint": { "host": "127.0.0.1", "port": 9191 },
16
+ "username": "user",
17
+ "privateKey": { "source": "file", "value": "~/.ssh/id_ed25519" },
18
+ "reachability": "zrok",
19
+ "keepaliveIntervalMs": 10000
20
+ },
21
+ "localForwards": [
22
+ {
23
+ "id": "home-assistant",
24
+ "listen": { "host": "127.0.0.1", "port": 8123 },
25
+ "target": { "host": "127.0.0.1", "port": 8123 }
26
+ },
27
+ {
28
+ "id": "cameras",
29
+ "listen": { "host": "127.0.0.1", "port": 8080 },
30
+ "target": { "host": "127.0.0.1", "port": 8080 }
31
+ }
32
+ ],
33
+ "portPolicy": {
34
+ "allowedPrivilegedPorts": [22],
35
+ "allowedLocalListenPorts": [8123, 8080],
36
+ "allowedRemoteHosts": ["127.0.0.1"]
37
+ },
38
+ "reconnect": {
39
+ "enabled": true,
40
+ "initialDelayMs": 1000,
41
+ "maxDelayMs": 30000,
42
+ "maxAttempts": 0
43
+ }
44
+ },
45
+
46
+ {
47
+ "name": "vps-fs",
48
+ "transport": {
49
+ "endpoint": { "host": "host.example.net", "port": 22 },
50
+ "username": "user",
51
+ "privateKey": { "source": "file", "value": "~/.ssh/id_ed25519" }
52
+ },
53
+ "localForwards": [
54
+ {
55
+ "id": "sshd",
56
+ "listen": { "host": "127.0.0.1", "port": 2222 },
57
+ "target": { "host": "127.0.0.1", "port": 22 }
58
+ }
59
+ ],
60
+ "portPolicy": {
61
+ "allowedPrivilegedPorts": [22],
62
+ "allowedLocalListenPorts": [2222],
63
+ "allowedRemoteHosts": ["127.0.0.1"]
64
+ }
65
+ },
66
+
67
+ {
68
+ "name": "vps-mariadb",
69
+ "transport": {
70
+ "endpoint": { "host": "host.example.net", "port": 22 },
71
+ "username": "user",
72
+ "privateKey": { "source": "file", "value": "~/.ssh/id_ed25519" }
73
+ },
74
+ "localForwards": [
75
+ {
76
+ "id": "mariadb",
77
+ "listen": { "host": "127.0.0.1", "port": 13306 },
78
+ "target": { "host": "127.0.0.1", "port": 3306 }
79
+ }
80
+ ],
81
+ "portPolicy": {
82
+ "allowedLocalListenPorts": [13306],
83
+ "allowedRemoteHosts": ["127.0.0.1"]
84
+ }
85
+ }
86
+ ]
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # Example of a plain OpenSSH reverse forward. The library replaces this
3
+ # pattern when you need reconnect, state, and more than one connection.
4
+ #
5
+ # Replace user@host.example.net with your SSH login.
6
+
7
+ ssh -N \
8
+ -o ExitOnForwardFailure=yes \
9
+ -o ServerAliveInterval=30 \
10
+ -o ServerAliveCountMax=3 \
11
+ -R 127.0.0.1:8380:127.0.0.1:22 \
12
+ user@host.example.net
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * examples/tunnel-cli.js
4
+ *
5
+ * Example CLI rebuilt on the v2 API:
6
+ *
7
+ * node examples/tunnel-cli.js home
8
+ * node examples/tunnel-cli.js all
9
+ *
10
+ * looks up the named entry (or every entry that is not `"disabled": true`)
11
+ * in a config file, brings up its forwards, and holds the tunnel open until
12
+ * Ctrl-C, reconnecting on its own if the link drops.
13
+ *
14
+ * Why this lives in examples/ rather than in the library
15
+ * -----------------------------------------------------
16
+ * v1 read ~/.config/ssh_tunnel_proxy/config.json from inside the library. That made
17
+ * behaviour depend on the machine, made tests depend on the developer's home
18
+ * directory, and meant a library decided where your configuration lived. v2 takes a
19
+ * config object and nothing else. Reading a file, choosing its location, and merging
20
+ * command-line overrides are application concerns — this file is that application,
21
+ * in about 200 lines of dependency-free JavaScript, and you should copy and edit it.
22
+ *
23
+ * Config file format
24
+ * ------------------
25
+ * An array of entries. Both shapes are accepted:
26
+ *
27
+ * v1 (what you already have on disk):
28
+ * { "hostname": "home", "username": "user", "host": "127.0.0.1",
29
+ * "port": "9191", "private_key_filename": "~/.ssh/id_ed25519",
30
+ * "proxy_ports": ["8280:127.0.0.1:8080"], "whitelist": { "22": true } }
31
+ *
32
+ * v2 (anything TunnelConfig accepts, plus a "name" to select it by):
33
+ * { "name": "home", "transport": { ... }, "localForwards": [ ... ] }
34
+ *
35
+ * Selection order for the config file: --config, then $SSH_TUNNEL_PROXY_CONFIG,
36
+ * then ~/.config/ssh_tunnel_proxy/config.json.
37
+ *
38
+ * Run `node examples/tunnel-cli.js --help` for the full option list.
39
+ * Requires `npm run build` first, since it loads the compiled build/.
40
+ *
41
+ * License: MIT
42
+ */
43
+
44
+ 'use strict';
45
+
46
+ const { existsSync, readFileSync } = require('node:fs');
47
+ const { homedir } = require('node:os');
48
+ const { join, resolve } = require('node:path');
49
+ const { Worker } = require('node:worker_threads');
50
+
51
+ const {
52
+ credential,
53
+ legacyConfigToTunnelConfig,
54
+ parseAndValidateLocalForward,
55
+ parseAndValidateRemoteForward,
56
+ whitelistToPortPolicy,
57
+ } = require('../build');
58
+
59
+ const WORKER = join(__dirname, 'tunnel-worker.js');
60
+
61
+ const DEFAULT_CONFIG_PATH = join(homedir(), '.config', 'ssh_tunnel_proxy', 'config.json');
62
+
63
+ // v1 options that v2 removed. Rejecting a whole config file over a stale key would
64
+ // be unhelpful for a CLI, so warn loudly and continue.
65
+ const REMOVED_KEYS = {
66
+ ngrok_api: 'resolve the address yourself and put it in host/port',
67
+ service_name: 'use private_key_filename, an env var, or ssh-agent',
68
+ server_name: 'no longer used (it only named a keychain entry)',
69
+ shell: 'interactive shells were removed; use ssh, or pass a command to run',
70
+ };
71
+
72
+ const USAGE = `
73
+ ssh_tunnel_proxy example CLI
74
+
75
+ Usage
76
+ node examples/tunnel-cli.js [options] <alias|user@host> [-- command ...]
77
+
78
+ Arguments
79
+ alias Name of an entry in the config file ("hostname" in a v1 entry,
80
+ "name" or "id" in a v2 entry).
81
+ all Bring up every entry that is not marked "disabled": true.
82
+ user@host Connect ad hoc, with no config file involved.
83
+ -- command ... Run a command over the tunnel instead of holding it open.
84
+ Not valid with "all".
85
+
86
+ Options
87
+ -F, --config <path> Config file. Default: $SSH_TUNNEL_PROXY_CONFIG or
88
+ ~/.config/ssh_tunnel_proxy/config.json
89
+ -l, --list List the entries in the config file and exit.
90
+ -p, --port <port> Override the SSH port.
91
+ -u, --user <name> Override the SSH username.
92
+ -i, --identity <path> Override the private key file. Use "agent" for ssh-agent.
93
+ -L <spec> Add a local forward, "[bind:]port:host:hostport".
94
+ Repeatable. Replaces the entry's forwards if given.
95
+ -R <spec> Add a reverse forward, "bindport:host:hostport".
96
+ Repeatable. Note: unlike v1, the FIRST port is the one the
97
+ peer binds. See MIGRATION.md.
98
+ -s, --status <sec> Print a connection report every N seconds. 0 = off.
99
+ --no-reconnect Exit on the first transport loss instead of retrying.
100
+ -v, --verbose Print tunnel debug output.
101
+ --debug-ssh Also print ssh2 protocol debug output.
102
+ -h, --help Show this help.
103
+
104
+ Examples
105
+ node examples/tunnel-cli.js home
106
+ node examples/tunnel-cli.js home -L 9090:127.0.0.1:3000 -v
107
+ node examples/tunnel-cli.js user@127.0.0.1 -p 9191 -i ~/.ssh/id_ed25519 \\
108
+ -L 8123:127.0.0.1:8123
109
+ node examples/tunnel-cli.js home -- uname -a
110
+ node examples/tunnel-cli.js all -F ./config.json
111
+ `;
112
+
113
+ function fail(message) {
114
+ console.error(`error: ${message}`);
115
+ process.exit(1);
116
+ }
117
+
118
+ /** Minimal argv parser: no dependencies, and `--` ends option parsing. */
119
+ function parseArgs(argv) {
120
+ const opts = { localForwards: [], remoteForwards: [], reconnect: true, statusSeconds: 0 };
121
+ const rest = [];
122
+ const command = [];
123
+ let afterDoubleDash = false;
124
+
125
+ const next = (i, flag) => {
126
+ const value = argv[i + 1];
127
+ if (value === undefined) fail(`${flag} requires a value`);
128
+ return value;
129
+ };
130
+
131
+ for (let i = 0; i < argv.length; i += 1) {
132
+ const arg = argv[i];
133
+ if (afterDoubleDash) {
134
+ command.push(arg);
135
+ continue;
136
+ }
137
+ switch (arg) {
138
+ case '--': afterDoubleDash = true; break;
139
+ case '-h': case '--help': console.log(USAGE.trim()); process.exit(0); break;
140
+ case '-l': case '--list': opts.list = true; break;
141
+ case '-v': case '--verbose': opts.verbose = true; break;
142
+ case '--debug-ssh': opts.debugSsh = true; break;
143
+ case '--no-reconnect': opts.reconnect = false; break;
144
+ case '-F': case '--config': opts.configPath = next(i, arg); i += 1; break;
145
+ case '-p': case '--port': opts.port = next(i, arg); i += 1; break;
146
+ case '-u': case '--user': opts.user = next(i, arg); i += 1; break;
147
+ case '-i': case '--identity': opts.identity = next(i, arg); i += 1; break;
148
+ case '-L': opts.localForwards.push(next(i, arg)); i += 1; break;
149
+ case '-R': opts.remoteForwards.push(next(i, arg)); i += 1; break;
150
+ case '-s': case '--status': opts.statusSeconds = Number(next(i, arg)); i += 1; break;
151
+ default:
152
+ if (arg.startsWith('-')) fail(`unknown option ${arg}`);
153
+ rest.push(arg);
154
+ }
155
+ }
156
+
157
+ opts.target = rest[0];
158
+ opts.command = command.join(' ');
159
+ return opts;
160
+ }
161
+
162
+ function loadConfigFile(explicitPath) {
163
+ const path = explicitPath
164
+ ? resolve(explicitPath)
165
+ : process.env.SSH_TUNNEL_PROXY_CONFIG || DEFAULT_CONFIG_PATH;
166
+
167
+ if (!existsSync(path)) return { path, entries: null };
168
+
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
172
+ } catch (err) {
173
+ fail(`cannot parse ${path}: ${err.message}`);
174
+ }
175
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
176
+ return { path, entries };
177
+ }
178
+
179
+ function entryName(entry) {
180
+ return entry.name ?? entry.id ?? entry.hostname ?? entry.host ?? '(unnamed)';
181
+ }
182
+
183
+ function warnRemovedKeys(entry) {
184
+ for (const [key, advice] of Object.entries(REMOVED_KEYS)) {
185
+ if (entry[key] === undefined || entry[key] === null || entry[key] === false) continue;
186
+ console.warn(`warning: "${key}" is ignored in v2 — ${advice}`);
187
+ delete entry[key];
188
+ }
189
+ }
190
+
191
+ /** Turn a config-file entry, of either generation, into a v2 TunnelConfig. */
192
+ function toTunnelConfig(entry) {
193
+ warnRemovedKeys(entry);
194
+
195
+ // Already a v2 config: use it as-is.
196
+ if (entry.transport) {
197
+ const { name, ...config } = entry;
198
+ return { id: config.id ?? name, ...config };
199
+ }
200
+
201
+ // v1 entry. The translator handles auth and transport; forwards are spec strings,
202
+ // so parse them here into the config rather than adding them after connect. That
203
+ // way a reconnect re-establishes them without the CLI doing anything.
204
+ const config = legacyConfigToTunnelConfig(entry);
205
+ config.id = entry.hostname ?? config.id;
206
+
207
+ const policy = whitelistToPortPolicy(entry.whitelist);
208
+ config.localForwards = (entry.proxy_ports ?? []).map((spec) =>
209
+ parseAndValidateLocalForward(spec, policy),
210
+ );
211
+ config.remoteForwards = (entry.remote_ports ?? []).map((spec) =>
212
+ parseAndValidateRemoteForward(spec, policy),
213
+ );
214
+ return config;
215
+ }
216
+
217
+ function configFromUserHost(target, opts) {
218
+ const [username, host] = target.includes('@') ? target.split('@') : [opts.user, target];
219
+ if (!username) fail('no username: use user@host or -u <name>');
220
+
221
+ const identity = opts.identity ?? '~/.ssh/id_ed25519';
222
+ return {
223
+ id: target,
224
+ transport: {
225
+ endpoint: { host, port: Number(opts.port ?? 22) },
226
+ username,
227
+ privateKey: identity === 'agent' ? credential.agent() : credential.file(identity),
228
+ },
229
+ };
230
+ }
231
+
232
+ function applyOverrides(config, opts) {
233
+ if (opts.port) config.transport.endpoint.port = Number(opts.port);
234
+ if (opts.user) config.transport.username = opts.user;
235
+ if (opts.identity) {
236
+ config.transport.privateKey =
237
+ opts.identity === 'agent' ? credential.agent() : credential.file(opts.identity);
238
+ }
239
+ if (opts.localForwards.length > 0) {
240
+ config.localForwards = opts.localForwards.map((spec) =>
241
+ parseAndValidateLocalForward(spec, config.portPolicy ?? {}),
242
+ );
243
+ }
244
+ if (opts.remoteForwards.length > 0) {
245
+ config.remoteForwards = opts.remoteForwards.map((spec) =>
246
+ parseAndValidateRemoteForward(spec, config.portPolicy ?? {}),
247
+ );
248
+ }
249
+ if (!opts.reconnect) config.reconnect = { enabled: false };
250
+ return config;
251
+ }
252
+
253
+ function isDisabled(entry) {
254
+ return entry.disabled === true || entry.disabled === 'true' || entry.disabled === 1;
255
+ }
256
+
257
+ function describeForward(forward) {
258
+ const arrow = forward.direction === 'local' ? '->' : '<-';
259
+ const port = forward.assignedPort ?? forward.listen.port;
260
+ return (
261
+ ` ${forward.direction === 'local' ? 'L' : 'R'} ${forward.listen.host}:${port} ` +
262
+ `${arrow} ${forward.target.host}:${forward.target.port} [${forward.state}]`
263
+ );
264
+ }
265
+
266
+ async function main() {
267
+ const opts = parseArgs(process.argv.slice(2));
268
+ const { path, entries } = loadConfigFile(opts.configPath);
269
+
270
+ if (opts.list) {
271
+ if (!entries) fail(`no config file at ${path}`);
272
+ console.log(`${path}:`);
273
+ for (const entry of entries) {
274
+ const generation = entry.transport ? 'v2' : 'v1';
275
+ const where = entry.transport
276
+ ? `${entry.transport.endpoint.host}:${entry.transport.endpoint.port}`
277
+ : `${entry.host ?? entry.hostname}:${entry.port ?? 22}`;
278
+ const forwards = (entry.proxy_ports ?? entry.localForwards ?? []).length;
279
+ const flag = entry.disabled ? ' (disabled)' : '';
280
+ console.log(` ${entryName(entry).padEnd(16)} ${generation} ${where} ${forwards} forward(s)${flag}`);
281
+ }
282
+ return;
283
+ }
284
+
285
+ if (!opts.target) {
286
+ console.error(USAGE.trim());
287
+ process.exit(2);
288
+ }
289
+
290
+ const sessions = [];
291
+
292
+ function startSession(config, label) {
293
+ return new Promise((resolve) => {
294
+ const worker = new Worker(WORKER, {
295
+ workerData: {
296
+ config,
297
+ label,
298
+ verbose: Boolean(opts.verbose),
299
+ debugSsh: Boolean(opts.debugSsh),
300
+ },
301
+ });
302
+ const session = { worker, label, state: 'connecting', ready: false };
303
+ sessions.push(session);
304
+
305
+ const finish = (ok) => {
306
+ if (session.settled) return;
307
+ session.settled = true;
308
+ resolve(ok ? session : null);
309
+ };
310
+
311
+ worker.on('message', (msg) => {
312
+ if (msg.type === 'log') console.error(msg.line);
313
+ if (msg.type === 'ready') {
314
+ session.ready = true;
315
+ session.state = 'ready';
316
+ console.error(`connected to ${msg.username}@${msg.host}:${msg.port} [${label}]`);
317
+ finish(true);
318
+ }
319
+ if (msg.type === 'failed') {
320
+ session.state = 'failed';
321
+ console.error(`error [${label} ${msg.code}] ${msg.message}`);
322
+ if (msg.code === 'AUTH_FAILED') {
323
+ console.error(" check that your public key is in the peer's authorized_keys");
324
+ }
325
+ if (msg.code === 'CHANNEL_OPEN_FAILED' || msg.code === 'TRANSPORT_NOT_READY') {
326
+ const ep = config.transport.endpoint;
327
+ console.error(` is a raw SSH listener really at ${ep.host}:${ep.port}?`);
328
+ console.error(` check with: nc ${ep.host} ${ep.port}`);
329
+ }
330
+ finish(false);
331
+ }
332
+ if (msg.type === 'state') session.state = msg.current;
333
+ if (msg.type === 'status') {
334
+ console.error(`-- [${label}] ${msg.state}, ${msg.connections.length} open connection(s)`);
335
+ for (const connection of msg.connections) {
336
+ console.error(
337
+ ` ${connection.forwardId} ${connection.state} ` +
338
+ `up ${connection.bytesToTarget}B down ${connection.bytesFromTarget}B`,
339
+ );
340
+ }
341
+ }
342
+ if (msg.type === 'exec-result' && session.execWait) {
343
+ session.execWait(msg);
344
+ session.execWait = undefined;
345
+ }
346
+ });
347
+
348
+ worker.on('error', (err) => {
349
+ console.error(`worker [${label}] ${err.message}`);
350
+ session.state = 'failed';
351
+ finish(false);
352
+ });
353
+
354
+ worker.on('exit', () => {
355
+ if (session.state !== 'failed') session.state = 'closed';
356
+ finish(false);
357
+ const live = sessions.filter((item) => item.state !== 'failed' && item.state !== 'closed');
358
+ if (session.settled && live.length === 0 && !session.holdingExit) {
359
+ if (sessions.some((item) => item.ready)) {
360
+ console.error('all tunnels closed; exiting');
361
+ process.exit(session.state === 'failed' ? 1 : 0);
362
+ }
363
+ }
364
+ });
365
+ });
366
+ }
367
+
368
+ function execOn(session, command) {
369
+ return new Promise((resolve) => {
370
+ session.execWait = resolve;
371
+ session.worker.postMessage({ op: 'exec', command });
372
+ });
373
+ }
374
+
375
+ if (opts.target === 'all') {
376
+ if (!entries) fail(`alias "all" given but no config file at ${path}`);
377
+ if (opts.command) fail('"all" cannot run a remote command; pick a single alias');
378
+ if (opts.localForwards.length > 0 || opts.remoteForwards.length > 0) {
379
+ fail('"all" cannot take -L / -R; those would collide across hosts. Set forwards on each entry');
380
+ }
381
+ const selected = entries.filter((entry) => !isDisabled(entry));
382
+ if (selected.length === 0) fail(`no enabled entries in ${path}`);
383
+ console.error(`starting ${selected.length} tunnel(s) from ${path}`);
384
+ const results = await Promise.all(
385
+ selected.map((entry) => {
386
+ const name = entryName(entry);
387
+ const config = applyOverrides(toTunnelConfig(entry), opts);
388
+ return startSession(config, name).then((session) => ({ name, session }));
389
+ }),
390
+ );
391
+ const down = results.filter((row) => !row.session);
392
+ for (const row of down) console.error(`skipped ${row.name}: connect failed`);
393
+ if (!results.some((row) => row.session)) process.exit(1);
394
+ } else {
395
+ let config;
396
+ let label;
397
+ if (opts.target.includes('@')) {
398
+ config = configFromUserHost(opts.target, opts);
399
+ label = opts.target;
400
+ } else {
401
+ if (!entries) fail(`alias "${opts.target}" given but no config file at ${path}`);
402
+ const entry = entries.find((candidate) => entryName(candidate) === opts.target);
403
+ if (!entry) {
404
+ fail(
405
+ `no entry named "${opts.target}" in ${path}. ` +
406
+ `Available: ${entries.map(entryName).join(', ') || '(none)'}`,
407
+ );
408
+ }
409
+ if (isDisabled(entry)) fail(`entry "${opts.target}" is disabled`);
410
+ config = toTunnelConfig(entry);
411
+ label = opts.target;
412
+ }
413
+
414
+ applyOverrides(config, opts);
415
+ const session = await startSession(config, label);
416
+ if (!session) process.exit(1);
417
+
418
+ if (opts.command) {
419
+ session.holdingExit = true;
420
+ const result = await execOn(session, opts.command);
421
+ process.stdout.write(result.stdout ?? '');
422
+ process.stderr.write(result.stderr ?? '');
423
+ session.worker.postMessage({ op: 'close' });
424
+ process.exit(result.code ?? 0);
425
+ }
426
+ }
427
+
428
+ const readyCount = sessions.filter((item) => item.ready).length;
429
+ console.error(`tunnel is up (${readyCount} ready). Ctrl-C to close.`);
430
+
431
+ let reporter;
432
+ if (opts.statusSeconds > 0) {
433
+ reporter = setInterval(() => {
434
+ for (const session of sessions) {
435
+ if (session.state === 'closed' || session.state === 'failed') continue;
436
+ session.worker.postMessage({ op: 'status' });
437
+ }
438
+ }, opts.statusSeconds * 1000);
439
+ reporter.unref?.();
440
+ }
441
+
442
+ const shutdown = async (signal) => {
443
+ console.error(`\n${signal}: closing`);
444
+ if (reporter) clearInterval(reporter);
445
+ await Promise.all(
446
+ sessions.map((session) => {
447
+ session.holdingExit = true;
448
+ return session.worker.terminate().catch(() => {});
449
+ }),
450
+ );
451
+ process.exit(0);
452
+ };
453
+ process.on('SIGINT', () => void shutdown('SIGINT'));
454
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
455
+ process.on('SIGHUP', () => void shutdown('SIGHUP'));
456
+ }
457
+
458
+ main().catch((err) => {
459
+ console.error(err);
460
+ process.exit(1);
461
+ });
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const { parentPort, workerData } = require('node:worker_threads');
4
+ const { SSHTunnel, isTunnelError } = require('../build');
5
+
6
+ const { config, label, verbose, debugSsh } = workerData;
7
+
8
+ function send(message) {
9
+ parentPort.postMessage(message);
10
+ }
11
+
12
+ function describeForward(forward) {
13
+ const arrow = forward.direction === 'local' ? '->' : '<-';
14
+ const port = forward.assignedPort ?? forward.listen.port;
15
+ return (
16
+ ` ${forward.direction === 'local' ? 'L' : 'R'} ${forward.listen.host}:${port} ` +
17
+ `${arrow} ${forward.target.host}:${forward.target.port} [${forward.state}]`
18
+ );
19
+ }
20
+
21
+ const tunnel = new SSHTunnel(config, { debugSsh: Boolean(debugSsh) });
22
+
23
+ if (verbose) {
24
+ tunnel.on('debug', (message, ...args) => {
25
+ send({ type: 'log', line: `debug[${label}]: ${message} ${args.map(String).join(' ')}`.trim() });
26
+ });
27
+ tunnel.on('forward', ({ status, previous }) => {
28
+ send({
29
+ type: 'log',
30
+ line: `forward[${label}] ${status.forwardId}: ${previous} -> ${status.state}`,
31
+ });
32
+ });
33
+ }
34
+
35
+ tunnel.on('reconnect', ({ attempt, delayMs, cause }) => {
36
+ send({
37
+ type: 'log',
38
+ line: `link lost [${label}] (${cause ?? 'unknown'}); retry ${attempt} in ${delayMs}ms`,
39
+ });
40
+ });
41
+
42
+ tunnel.on('error', (err) => {
43
+ send({ type: 'log', line: `[${label} ${err.code ?? 'ERROR'}] ${err.message}` });
44
+ });
45
+
46
+ tunnel.on('ready', (status) => {
47
+ for (const forward of status.forwards) send({ type: 'log', line: describeForward(forward) });
48
+ });
49
+
50
+ tunnel.on('state', ({ current }) => {
51
+ send({ type: 'state', current });
52
+ });
53
+
54
+ parentPort.on('message', async (message) => {
55
+ if (message?.op === 'close') {
56
+ await tunnel.close().catch(() => {});
57
+ process.exit(0);
58
+ }
59
+ if (message?.op === 'exec') {
60
+ try {
61
+ const result = await tunnel.exec(message.command);
62
+ send({ type: 'exec-result', ...result });
63
+ } catch (err) {
64
+ send({
65
+ type: 'exec-result',
66
+ code: 1,
67
+ stdout: '',
68
+ stderr: err instanceof Error ? err.message : String(err),
69
+ });
70
+ }
71
+ }
72
+ if (message?.op === 'status') {
73
+ send({
74
+ type: 'status',
75
+ state: tunnel.getState(),
76
+ connections: tunnel.listConnections(),
77
+ });
78
+ }
79
+ });
80
+
81
+ tunnel
82
+ .connect()
83
+ .then((status) => {
84
+ send({
85
+ type: 'ready',
86
+ username: config.transport.username,
87
+ host: status.endpoint.host,
88
+ port: status.endpoint.port,
89
+ });
90
+ })
91
+ .catch((err) => {
92
+ if (isTunnelError(err)) {
93
+ send({ type: 'failed', code: err.code, message: err.message });
94
+ } else {
95
+ send({ type: 'failed', code: 'TRANSPORT_NOT_READY', message: String(err) });
96
+ }
97
+ setTimeout(() => process.exit(1), 20);
98
+ });