ssh_tunnel_proxy 2.1.3 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +19 -3
- package/MIGRATION.md +227 -0
- package/README.md +360 -212
- package/build/bridge.d.ts +14 -0
- package/build/bridge.d.ts.map +1 -0
- package/build/bridge.js +12 -0
- package/build/bridge.js.map +1 -0
- package/build/connection.d.ts +60 -0
- package/build/connection.d.ts.map +1 -0
- package/build/connection.js +198 -0
- package/build/connection.js.map +1 -0
- package/build/credentials.d.ts +24 -0
- package/build/credentials.d.ts.map +1 -0
- package/build/credentials.js +102 -0
- package/build/credentials.js.map +1 -0
- package/build/errors.d.ts +19 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +64 -0
- package/build/errors.js.map +1 -0
- package/build/forward-spec.d.ts +34 -0
- package/build/forward-spec.d.ts.map +1 -0
- package/build/forward-spec.js +175 -0
- package/build/forward-spec.js.map +1 -0
- package/build/index.d.ts +19 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +71 -0
- package/build/index.js.map +1 -0
- package/build/legacy.d.ts +70 -0
- package/build/legacy.d.ts.map +1 -0
- package/build/legacy.js +188 -0
- package/build/legacy.js.map +1 -0
- package/build/local-forward.d.ts +31 -0
- package/build/local-forward.d.ts.map +1 -0
- package/build/local-forward.js +231 -0
- package/build/local-forward.js.map +1 -0
- package/build/model.d.ts +126 -0
- package/build/model.d.ts.map +1 -0
- package/build/model.js +55 -0
- package/build/model.js.map +1 -0
- package/build/port-policy.d.ts +43 -0
- package/build/port-policy.d.ts.map +1 -0
- package/build/port-policy.js +138 -0
- package/build/port-policy.js.map +1 -0
- package/build/remote-forward.d.ts +40 -0
- package/build/remote-forward.d.ts.map +1 -0
- package/build/remote-forward.js +226 -0
- package/build/remote-forward.js.map +1 -0
- package/build/tunnel.d.ts +121 -0
- package/build/tunnel.d.ts.map +1 -0
- package/build/tunnel.js +599 -0
- package/build/tunnel.js.map +1 -0
- package/examples/config.example.json +86 -0
- package/examples/setup_ssh.sh +12 -0
- package/examples/tunnel-cli.js +461 -0
- package/examples/tunnel-worker.js +98 -0
- package/examples/zrok-termux.ts +147 -0
- package/package.json +43 -53
- package/proto/sshtunnel/v1/tunnel.proto +343 -0
- package/dist/TestMessage.d.ts +0 -13
- package/dist/TestMessage.js +0 -32
- package/dist/TestMessage.js.map +0 -1
- package/dist/cjs/TestMessage.d.ts +0 -13
- package/dist/cjs/TestMessage.js +0 -32
- package/dist/cjs/TestMessage.js.map +0 -1
- package/dist/cjs/index.d.ts +0 -73
- package/dist/cjs/index.js +0 -621
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/keypair_storage.d.ts +0 -12
- package/dist/cjs/keypair_storage.js +0 -143
- package/dist/cjs/keypair_storage.js.map +0 -1
- package/dist/cjs/ngrok_service.d.ts +0 -11
- package/dist/cjs/ngrok_service.js +0 -40
- package/dist/cjs/ngrok_service.js.map +0 -1
- package/dist/cjs/ssh2-node.d.ts +0 -1
- package/dist/cjs/ssh2-node.js +0 -221
- package/dist/cjs/ssh2-node.js.map +0 -1
- package/dist/esm/TestMessage.d.ts +0 -13
- package/dist/esm/TestMessage.js +0 -29
- package/dist/esm/TestMessage.js.map +0 -1
- package/dist/esm/index.d.ts +0 -73
- package/dist/esm/index.js +0 -616
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/keypair_storage.d.ts +0 -12
- package/dist/esm/keypair_storage.js +0 -117
- package/dist/esm/keypair_storage.js.map +0 -1
- package/dist/esm/ngrok_service.d.ts +0 -11
- package/dist/esm/ngrok_service.js +0 -37
- package/dist/esm/ngrok_service.js.map +0 -1
- package/dist/esm/ssh2-node.d.ts +0 -1
- package/dist/esm/ssh2-node.js +0 -194
- package/dist/esm/ssh2-node.js.map +0 -1
- package/dist/index.d.ts +0 -73
- package/dist/index.js +0 -620
- package/dist/index.js.map +0 -1
- package/dist/keypair_storage.d.ts +0 -12
- package/dist/keypair_storage.js +0 -120
- package/dist/keypair_storage.js.map +0 -1
- package/dist/ngrok_service.d.ts +0 -11
- package/dist/ngrok_service.js +0 -40
- package/dist/ngrok_service.js.map +0 -1
- package/dist/ssh2-node.d.ts +0 -1
- package/dist/ssh2-node.js +0 -188
- package/dist/ssh2-node.js.map +0 -1
- package/dist/types/TestMessage.d.ts +0 -13
- package/dist/types/index.d.ts +0 -73
- package/dist/types/keypair_storage.d.ts +0 -12
- package/dist/types/ngrok_service.d.ts +0 -11
- package/dist/types/ssh2-node.d.ts +0 -1
- 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
|
+
});
|