ssh_tunnel_proxy 2.1.3 → 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 -221
  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 -194
  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
package/README.md CHANGED
@@ -1,267 +1,415 @@
1
1
  # ssh_tunnel_proxy
2
- Initiate a ssh reverse tunnel proxy with forwarding ports
3
2
 
4
- ssh_tunnel_proxy is a wrapper to ssh2 that provides async functionality as well as an extension to the api to include methods to setup a list of proxy forwards, exec a list of commands or startup a terminal shell. To setup a ssh tunnel, parameters are suppled for host, port, authentication and a list of proxy ports or commands to invoke. If a ngrok api key is provided the host and port of the ngrok tunnel are obtained. If the connection is interrupted or connection errors occur, attempts are made re-establish the tunnel.
3
+ SSH port forwarding for Node.js. You give it an SSH listener that already exists
4
+ at `host:port`. It keeps local (`ssh -L`) and reverse (`ssh -R`) forwards up over
5
+ that connection.
5
6
 
6
- In addition to the node api, a command line function called ssh-node2 is included to start ssh sessions in a manner similar to the ssh command line utility.
7
+ ```text
8
+ browser ──▶ 127.0.0.1:8280 ──▶ [ SSH ] ──▶ 127.0.0.1:8123 on the far side
9
+ ```
7
10
 
8
- ### Command line examples
11
+ The only runtime dependency is [`ssh2`](https://github.com/mscdex/ssh2).
9
12
 
10
- Initialize ssh2 command:
11
- ```
12
- npm i ssh_tunnel_proxy -g
13
- ```
14
- Connect to remote host and establish local forwards:
13
+ ## Why not `ssh -L`?
14
+
15
+ OpenSSH already does the same forwarding. Use it when a single hop stays up and
16
+ you are fine supervising the process yourself.
17
+
18
+ This package is for the hop that does not stay up — a phone, a NAT mapping, a
19
+ zrok or ngrok TCP share — and for code that has to *do something* when it drops.
15
20
 
21
+ | `ssh -L` / `ssh -R` | `ssh_tunnel_proxy` |
22
+ | --- | --- |
23
+ | A process you start and hope stays running | An object with `connect()`, `close()`, and events |
24
+ | Local port vanishes on drop (`ECONNREFUSED`, then a race to bind it again) | Listener stays bound in `degraded`; new clients fail fast; same port comes back |
25
+ | `ServerAliveInterval` or a restart loop in a shell | Bounded reconnect with backoff, or a hard stop at `maxAttempts` |
26
+ | stderr and exit codes | `TunnelError` with a stable `code` (`AUTH_FAILED`, `TARGET_UNREACHABLE`, …) |
27
+ | One shared stream per reverse forward in naive wrappers | One SSH channel and one TCP socket per connection |
28
+ | No picture of what is open | `getStatus()`, `listConnections()`, byte counts |
29
+
30
+ It does not replace `sshd`, generate keys, or create the relay. It is the layer
31
+ on top of an SSH listener that keeps forwards honest when the path is flaky.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ npm install ssh_tunnel_proxy
16
37
  ```
17
- ssh2-node -i ~/.ssh/<private_key> -L 8180:192.168.1.1:80 <username>@<hostname>
38
+
39
+ The package includes `examples/tunnel-cli.js`. After `npm run build` (or from a
40
+ clone), you can bring forwards up with no application code.
41
+
42
+ ## Quick start: the CLI
43
+
44
+ Copy `examples/config.example.json`, point each `transport.endpoint` at a host
45
+ that already speaks SSH, and give every service its own loopback listen port.
46
+
47
+ ```json
48
+ [
49
+ {
50
+ "name": "pi-living",
51
+ "transport": {
52
+ "endpoint": { "host": "192.168.1.21", "port": 22 },
53
+ "username": "user",
54
+ "privateKey": { "source": "file", "value": "~/.ssh/id_ed25519" }
55
+ },
56
+ "localForwards": [
57
+ { "id": "ha", "listen": { "host": "127.0.0.1", "port": 8123 }, "target": { "host": "127.0.0.1", "port": 8123 } }
58
+ ]
59
+ },
60
+ {
61
+ "name": "pi-garage",
62
+ "transport": {
63
+ "endpoint": { "host": "192.168.1.22", "port": 22 },
64
+ "username": "user",
65
+ "privateKey": { "source": "file", "value": "~/.ssh/id_ed25519" }
66
+ },
67
+ "localForwards": [
68
+ { "id": "cameras", "listen": { "host": "127.0.0.1", "port": 8080 }, "target": { "host": "127.0.0.1", "port": 8080 } }
69
+ ],
70
+ "disabled": false
71
+ }
72
+ ]
18
73
  ```
19
74
 
20
- Connect to host with parameters stored in ~/.config/ssh_tunnel_proxy/config.json:
75
+ The example CLI starts one `worker_threads` Worker per config entry, so
76
+ each session has its own event loop. A blocked tick or a drop on one
77
+ board does not stall the others, and a local port stays bound while that
78
+ hop reconnects.
21
79
 
80
+ ```bash
81
+ node examples/tunnel-cli.js --list -F ./config.json
82
+ node examples/tunnel-cli.js all -F ./config.json
22
83
  ```
23
- ssh2-node rh2
84
+
85
+ `all` starts every entry that is not `"disabled": true`. Open
86
+ `http://127.0.0.1:8123` and `http://127.0.0.1:8080` in a browser on this
87
+ machine. Ctrl-C closes the lot.
88
+
89
+ A single host, or an override without editing the file:
90
+
91
+ ```bash
92
+ node examples/tunnel-cli.js pi-living -F ./config.json
93
+ node examples/tunnel-cli.js pi-living -F ./config.json -L 9090:127.0.0.1:3000
94
+ node examples/tunnel-cli.js user@192.168.1.21 -i ~/.ssh/id_ed25519 \
95
+ -L 8123:127.0.0.1:8123
24
96
  ```
25
97
 
26
- default config file, located at:
27
- ~/.config/ssh_tunnel_proxy/config.json
98
+ Config path: `-F` / `--config`, else `$SSH_TUNNEL_PROXY_CONFIG`, else
99
+ `~/.config/ssh_tunnel_proxy/config.json`. v1 and v2 entry shapes are both
100
+ accepted. The CLI is an example on purpose — the library itself still takes a
101
+ `TunnelConfig` object and does not read a file.
102
+
103
+ Two config entries are two SSH sessions — and two Workers — even if they
104
+ point at the same host. That is how you isolate a filesystem mount from
105
+ a database on one VPS. Give each entry its own listen ports.
106
+
28
107
  ```json
29
- [{
30
- "hostname":"rh2",
31
- "username": "<username>",
32
- "proxy_ports": [
33
- "8280:127.0.0.1:80",
34
- "9000:127.0.0.1:9000",
35
- "8122:192.168.2.1:22"
36
- ],
37
- "private_key_filename":"~/.ssh/<private key>",
38
- "ngrok_api": "<ngrok api key>"
39
- }]
108
+ [
109
+ {
110
+ "name": "vps-fs",
111
+ "transport": {
112
+ "endpoint": { "host": "host.example.net", "port": 22 },
113
+ "username": "user",
114
+ "privateKey": { "source": "file", "value": "~/.ssh/id_ed25519" }
115
+ },
116
+ "localForwards": [
117
+ { "id": "sshd", "listen": { "host": "127.0.0.1", "port": 2222 }, "target": { "host": "127.0.0.1", "port": 22 } }
118
+ ],
119
+ "portPolicy": { "allowedPrivilegedPorts": [22], "allowedRemoteHosts": ["127.0.0.1"] }
120
+ },
121
+ {
122
+ "name": "vps-mariadb",
123
+ "transport": {
124
+ "endpoint": { "host": "host.example.net", "port": 22 },
125
+ "username": "user",
126
+ "privateKey": { "source": "file", "value": "~/.ssh/id_ed25519" }
127
+ },
128
+ "localForwards": [
129
+ { "id": "mariadb", "listen": { "host": "127.0.0.1", "port": 13306 }, "target": { "host": "127.0.0.1", "port": 3306 } }
130
+ ]
131
+ }
132
+ ]
40
133
  ```
41
134
 
42
- Execute command on remote host
135
+ ```bash
136
+ node examples/tunnel-cli.js all -F ./config.json
137
+ sshfs -p 2222 user@127.0.0.1:/var/www ~/mnt/vps -o reconnect
138
+ # MariaDB clients: 127.0.0.1:13306
43
139
  ```
44
- ssh2-node rh2 "ls -all"
140
+
141
+ A flap on the SFTP hop does not take the database session with it.
142
+
143
+ ### Do not block the event loop
144
+
145
+ `ssh2` and this library run on Node’s single JS thread. A synchronous
146
+ `JSON.parse` of a large file, a tight CPU loop, or a giant
147
+ `readFileSync` in the **same process** pauses *every* tunnel in that
148
+ process: accepts, channel data, keepalives, and reconnect timers.
149
+
150
+ The example CLI is safe because it only reads a small config and then
151
+ moves bytes. If you embed `SSHTunnel` in an app that also parses,
152
+ indexes, or transforms large payloads:
153
+
154
+ - Prefer `worker_threads` (or a child process) for that work.
155
+ - Keep the process that calls `connect()` dedicated to SSH and local
156
+ sockets.
157
+ - Do not `JSON.parse` multi‑megabyte buffers on the tunnel tick.
158
+
159
+ The example CLI already puts each session on its own Worker. If you
160
+ embed `SSHTunnel` in a larger app, do the same — or use `worker_threads`
161
+ for parse/CPU and keep one dedicated Worker (or process) per transport.
162
+
163
+ ## Quick start: the library
164
+
165
+ Same forwards from your own process, for example Home Assistant at
166
+ `127.0.0.1:8123`:
167
+
168
+ ```ts
169
+ import { SSHTunnel, credential } from 'ssh_tunnel_proxy';
170
+
171
+ const tunnel = new SSHTunnel({
172
+ transport: {
173
+ endpoint: { host: '127.0.0.1', port: 9191 },
174
+ username: 'user',
175
+ privateKey: credential.file('~/.ssh/id_ed25519'),
176
+ },
177
+ localForwards: [
178
+ {
179
+ id: 'home-assistant',
180
+ listen: { host: '127.0.0.1', port: 8123 },
181
+ target: { host: '127.0.0.1', port: 8123 },
182
+ },
183
+ ],
184
+ });
185
+
186
+ tunnel.on('error', (err) => console.error(err.code, err.message));
187
+ await tunnel.connect();
188
+
189
+ const { endpoint } = tunnel.resolveEndpoint('home-assistant');
190
+ console.log(`open http://${endpoint.host}:${endpoint.port}`);
191
+
192
+ await tunnel.close();
45
193
  ```
46
194
 
47
- ### List of command-line options (compatible with original ssh2 command):
195
+ `9191` is whatever local port your relay (zrok, ngrok, Tailscale, or a LAN
196
+ address) already bound. This package does not create that listener.
197
+
198
+ ### Reverse forward
199
+
200
+ Expose a process on this machine to the peer (`ssh -R`):
201
+
202
+ ```ts
203
+ const status = await tunnel.addRemoteForward({
204
+ id: 'dev-server',
205
+ bind: { host: '127.0.0.1', port: 0 }, // 0 = peer chooses the port
206
+ target: { host: '127.0.0.1', port: 3000 },
207
+ });
208
+
209
+ console.log('peer is listening on', status.assignedPort);
48
210
  ```
49
- Usage: ssh2-node [options] [userhost] [exec...]
50
-
51
- Nodejs ssh2 command line client
52
-
53
- Arguments:
54
- userhost Connect to hostname in config.
55
- exec Command to exec on remote host (must be in quotes)
56
-
57
- Options:
58
- -V, --version output the version number
59
- -2 --protocolv2 [protocolv2] Forces ssh to try protocol version 2 only.
60
- -4 --ipv4only [ipv4only] Forces ssh to use IPv4 addresses only.
61
- -6 --ipv6only [ipv6only] Forces ssh to use IPv6 addresses only.
62
- -A --forward_enable [forward_enable] Enables forwarding of the authentication agent
63
- connection.
64
- -a --forward_disable [forward_disable] Disables forwarding of the authentication agent
65
- connection.
66
- -b --bind [bind] bind_address
67
- -C --compress [compress] Requests compression of all data
68
- -c --cipher [cipher] cipher_spec
69
- -D --dynamic_forward [dynamic_forward] Specifies a local 'dynamic' application-level
70
- port forwarding.
71
- -e --escape [escape] escape_char Sets the escape character for
72
- sessions with a pty.
73
- -F --config [config] configfile
74
- -f --background [background] Requests ssh to go to background just before
75
- command execution.
76
- -g --local_forward_remote [local_forward_remote] Allows remote hosts to connect to local forwarded
77
- ports.
78
- -I --smartcard [smartcard] smartcard_device Specify the device ssh.
79
- -i --identity [identity] Private key filename.
80
- -K --gssapi_auth [gssapi_auth] Enables GSSAPI-based authentication and
81
- forwarding (delegation) of GSSAPI credentials to
82
- the server.
83
- -k --gssapi_disable [gssapi_disable] Disables forwarding (delegation) of GSSAPI
84
- credentials to the server.
85
- -L --local_forward_out [local_forward_out...] bind_address:port:host:hostport
86
- -l --login_name [login_name] login_name
87
- -M --master_mode [master_mode] Places the ssh client into 'master' mode for
88
- connection sharing. Multiple -M options
89
- -m --mac_spec [mac_spec] mac_spec
90
- -N --no_exec [no_exec] Do not execute a remote command.
91
- -n --redirect_stdin [redirect_stdin] Redirects stdin from /dev/null
92
- -O --ctl_cmd [ctl_cmd] ctl_cmd
93
- -o --option [option] option
94
- -p --port [port] port Port to connect to on the remote host.
95
- -q --quiet [quiet] Quiet mode. Causes most warning and diagnostic
96
- messages to be suppressed.
97
- -R --local_forward_in [local_forward_in...] bind_address:port:host:hostport
98
- -S --ctl_path [ctl_path] ctl_path Specifies the location of a control
99
- socket for connection sharing.
100
- -s --subsystem [subsystem] May be used to request invocation of a subsystem
101
- on the remote system.
102
- -T --disable_tty [disable_tty] Disable pseudo-tty allocation.
103
- -t --force_tty [force_tty] Force pseudo-tty allocation.
104
- -V --version [version] Display the version number and exit.
105
- -v --verbose [verbose] Verbose mode. Causes ssh to print debugging
106
- messages about its progress.
107
- -W --forward_stdin_stdout [forward_stdin_stdout] host:port Requests that standard input and output
108
- on the client be forwarded to host on port ver
109
- the secure channel.
110
- -w --tunnel [tunnel] Requests tunnel device forwarding with the
111
- specified tun(4) devices between the client
112
- (local_tun) and the server (remote_tun).
113
- -X --x11 [x11] Enables X11 forwarding.
114
- -x --x11_disable [x11_disable] Disables X11 forwarding.
115
- -Y --x11_trusted [x11_trusted] Enables trusted X11 forwarding.
116
- -y --log [log] Send log information using the syslog(3)
117
- -H --ngrok [ngrok] Obtain connectiion hostport from ngrok
118
- -J --keychain_service [keychain_service] Service name to obtain private key from system
119
- keychain.
120
- -j --keychain_account [keychain_account] Account name to obtain private key from system
121
- keychain.
122
- -h, --help display help for command
211
+
212
+ Each inbound connection gets its own SSH channel and its own TCP socket.
213
+
214
+ ### Several services on one transport
215
+
216
+ ```ts
217
+ localForwards: [
218
+ { id: 'home-assistant', listen: { host: '127.0.0.1', port: 8123 }, target: { host: '127.0.0.1', port: 8123 } },
219
+ { id: 'cameras', listen: { host: '127.0.0.1', port: 8080 }, target: { host: '127.0.0.1', port: 8080 } },
220
+ ]
123
221
  ```
124
- ### Api examples
125
222
 
126
- Exec remote commands using async await and processing result through streams.
223
+ Open `http://127.0.0.1:8123` and `http://127.0.0.1:8080` in a browser on this
224
+ machine. Listeners bind loopback only.
127
225
 
128
- ```js
129
- // send result of cmd through pipeline, generating a stream of json objects
130
- function lsTest(cmdProxy, cmd, destination) {
226
+ ## What this package does not do
131
227
 
132
- return new Promise( (resolveCmd) => {
228
+ See [MIGRATION.md](./MIGRATION.md) for the v1 mapping.
133
229
 
134
- // set input of pipeline to split data into lines (npm i split)
135
- const tunnel = split();
230
+ | Out of scope | Do this instead |
231
+ | --- | --- |
232
+ | Key generation and keychains | `credential.file`, `credential.env`, `credential.agent`, or `credential.callback` |
233
+ | Creating a zrok / ngrok / Tailscale share | Start the relay yourself, then pass `host:port` |
234
+ | Interactive shells | Use `ssh`, or `tunnel.exec()` for one command |
235
+ | Reading `~/.config/ssh_tunnel_proxy/config.json` | Pass a `TunnelConfig` object |
136
236
 
137
- // when pipeline is ready exec shell cmd
138
- const pipelineReady = (socket) => {
237
+ ## Using a TCP relay (zrok, ngrok, …)
139
238
 
140
- return new Promise((resolve) => {
239
+ 1. On the host that runs `sshd` and the private apps, share the SSH port. With zrok:
141
240
 
142
- // invoke command on remote host and send results to pipeline
143
- cmdProxy.execCmd(cmd, tunnel)
241
+ ```bash
242
+ zrok share private --backend-mode tcpTunnel 127.0.0.1:22
243
+ ```
144
244
 
145
- // stream processing complete, cleanup pipeline and exit
146
- .then(() => {
147
- //self.cleanupPipeline(socket);
148
- resolveCmd();
149
- });
150
- resolve();
151
- })
152
- }
245
+ 2. On the machine that will run this library, bind that share locally and check
246
+ that it really is SSH:
153
247
 
154
- // pipe shell cmd result through json parser pipeline to destination
155
- pipeline(tunnel,
156
- self.parse(),
157
- self.toJSONString(),
158
- destination,
159
- pipelineReady
160
- );
161
- })
162
- }
248
+ ```bash
249
+ zrok access private --bind 127.0.0.1:9191 <share-token>
250
+ nc 127.0.0.1 9191 # must print SSH-2.0-...
251
+ ```
163
252
 
164
- async function runCmd() {
253
+ 3. Point `transport.endpoint` at `127.0.0.1:9191`.
165
254
 
166
- const opts = {
167
- "hostname":"rh2",
168
- "username": "<username>",
169
- "private_key_filename":"~/.ssh/<private key>",
170
- "ngrok_api": "<ngrok api key>"
171
- }
255
+ A longer copy of this pattern is in `examples/zrok-termux.ts`.
256
+
257
+ ## API
258
+
259
+ ### `new SSHTunnel(config, options?)`
260
+
261
+ `TunnelConfig` matches `proto/sshtunnel/v1/tunnel.proto`.
262
+
263
+ | Method | Returns | Notes |
264
+ | --- | --- | --- |
265
+ | `connect()` | `Promise<TunnelStatus>` | Idempotent; concurrent calls share one attempt. |
266
+ | `close()` | `Promise<TunnelStatus>` | Idempotent. Stops reconnect and closes everything. |
267
+ | `addLocalForward(spec \| string)` | `Promise<ForwardStatus>` | Object or `"8280:127.0.0.1:8080"`. |
268
+ | `addLocalForwards(specs[])` | `Promise<ForwardStatus[]>` | Atomic: one bind failure rolls all of them back. |
269
+ | `addRemoteForward(spec \| string)` | `Promise<ForwardStatus>` | `bind.port: 0` → read `assignedPort`. |
270
+ | `addRemoteForwards(specs[])` | `Promise<ForwardStatus[]>` | Atomic, as above. |
271
+ | `removeForward(id)` | `Promise<ForwardStatus>` | Unbinds the listener and closes its connections. |
272
+ | `resolveEndpoint(id)` | `{ endpoint, state }` | Where to connect, including a peer-assigned port. |
273
+ | `getStatus()` | `TunnelStatus` | Tunnel + every forward. |
274
+ | `getForwardStatus(id)` | `ForwardStatus` | One forward. |
275
+ | `listConnections()` | `ConnectionStatus[]` | Live proxied connections and byte counts. |
276
+ | `getState()` / `isReady()` | `TunnelState` / `boolean` | |
277
+ | `exec(command, streams?)` | `Promise<{ code, stdout, stderr }>` | One command. No TTY. |
278
+
279
+ ### Events
280
+
281
+ | Event | Payload | When |
282
+ | --- | --- | --- |
283
+ | `state` | `{ previous, current, reason? }` | Tunnel state change. |
284
+ | `ready` | `TunnelStatus` | Transport up and forwards open. Also after a successful reconnect. |
285
+ | `forward` | `{ status, previous }` | A forward changes state. |
286
+ | `connection` | `{ status, previous }` | One proxied connection changes state. |
287
+ | `reconnect` | `{ attempt, delayMs, cause? }` | A retry is scheduled. |
288
+ | `error` | `TunnelError` | Always has a `code`. |
289
+ | `close` | `TunnelStatus` | Reached `closed`. |
290
+ | `debug` | `(message, ...args)` | Tracing. `options.debugSsh` enables ssh2 protocol logs. |
291
+
292
+ ### States
293
+
294
+ ```text
295
+ tunnel idle → connecting → ready ⇄ reconnecting → closing → closed
296
+ ↘ failed
297
+
298
+ forward pending → opening → active ⇄ degraded → closing → closed
299
+ ↘ failed
300
+
301
+ connection accepted → opening → piped → half_closed → closed
302
+ ↘ failed
303
+ ```
172
304
 
173
- const sshTunnelProxy = new SSHTunnelProxy();
305
+ `degraded`: the local listener stays bound while SSH is down. New clients fail
306
+ fast instead of getting `ECONNREFUSED` and racing to rebind. When SSH returns the
307
+ same forward becomes `active` again.
174
308
 
175
- // connect to remote host
176
- await sshTunnelProxy.connectSSH(opts);
309
+ ### Errors
177
310
 
178
- // invoke ls -all on remote host and parse result to json object string
179
- await lsTest(sshTunnelProxy, 'ls -all', process.stdout);
311
+ Branch on `err.code`, not on the message:
312
+
313
+ ```ts
314
+ import { isTunnelError } from 'ssh_tunnel_proxy';
315
+
316
+ try {
317
+ await tunnel.addLocalForward('80:127.0.0.1:8080');
318
+ } catch (err) {
319
+ if (isTunnelError(err) && err.code === 'PORT_NOT_PERMITTED') {
320
+ // privileged listen port was not allowlisted
321
+ }
180
322
  }
323
+ ```
324
+
325
+ `INVALID_FORWARD_SPEC`, `PORT_NOT_PERMITTED`, `HOST_NOT_PERMITTED`,
326
+ `TRANSPORT_NOT_READY`, `AUTH_FAILED`, `LISTEN_FAILED`, `REMOTE_BIND_FAILED`,
327
+ `CHANNEL_OPEN_FAILED`, `TARGET_UNREACHABLE`, `DUPLICATE_FORWARD_ID`,
328
+ `UNKNOWN_FORWARD`, `CONNECTION_LIMIT`, `RECONNECT_EXHAUSTED`,
329
+ `CREDENTIAL_UNRESOLVED`.
181
330
 
182
- runCmd();
331
+ ## Credentials
332
+
333
+ The library only resolves a reference. It never writes a secret.
334
+
335
+ ```ts
336
+ credential.file('~/.ssh/id_ed25519')
337
+ credential.env('SSH_PRIVATE_KEY')
338
+ credential.inline(pem)
339
+ credential.agent()
340
+ credential.callback('vault://key') // needs options.credentialResolver
183
341
  ```
184
342
 
185
- Example of use of the api with electronjs.
343
+ Optional host-key check:
186
344
 
187
- main.js:
188
- ```js
345
+ ```ts
346
+ transport: {
347
+ // ...
348
+ hostKeyFingerprints: ['sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU'],
349
+ }
350
+ ```
189
351
 
190
- async function init_sshTunnelProxy(win) {
352
+ Omit fingerprints only on a path that is already authenticated (a private share).
191
353
 
192
- const { SSHTunnelProxy } = require('ssh_tunnel_proxy');
193
- const sshTunnelProxy = new SSHTunnelProxy();
194
-
195
- ipcMain.on('connect_ssh_sync', async function(event,opts) {
196
- // only allow connection on remote server to system ports http,https,ssh and user ports >1023
197
- const portWhitelist = {
198
- 80:true,
199
- 443:true,
200
- 22:true
201
- }
202
- await sshTunnelProxy.connectSSH(opts, portWhitelist);
203
- event.returnValue = 'connected';
204
- });
354
+ ## Port policy
205
355
 
206
- ipcMain.on('generate_keypair', async (event, ...args) => await sshTunnelProxy.generateAndStoreKeypair(...args));
356
+ Local listen ports and remote target ports are separate decisions. Local listeners
357
+ must be loopback (`127.0.0.1` or `::1`).
207
358
 
208
- ipcMain.on('get_public_key', async (event, ...args) => await sshTunnelProxy.getPublicKey(...args));
359
+ ```ts
360
+ portPolicy: {
361
+ allowedPrivilegedPorts: [22, 80, 443],
362
+ allowedLocalListenPorts: [8123, 8080],
363
+ allowedRemoteTargetPorts: [8123, 8080, 22],
364
+ allowedRemoteHosts: ['127.0.0.1', '192.168.1.1'],
365
+ }
366
+ ```
367
+
368
+ Order: valid TCP port (remote bind may be `0`); ports below 1024 need
369
+ `allowedPrivilegedPorts`; a non-empty role list must include the port. No policy
370
+ means any unprivileged port, no privileged port.
209
371
 
210
- ipcMain.on('network_online', (event, ...args) => sshTunnelProxy.onNetworkOnline(...args));
372
+ ## Data model
211
373
 
212
- ipcMain.on('network_offline', (event, ...args) => sshTunnelProxy.onNetworkOffline(...args));
374
+ `proto/sshtunnel/v1/tunnel.proto` is the cross-language source of truth.
375
+ `src/model.ts` is a hand-written TypeScript projection; a unit test fails if they
376
+ drift. The runtime does not depend on protobuf.
213
377
 
214
- sshTunnelProxy.on('ready',(...args) => win.webContents.send('ready',...args));
378
+ ## Migrating from v1
215
379
 
216
- sshTunnelProxy.on('debug',(...args) => win.webContents.send('debug',...args));
380
+ `SSHTunnelProxy` still exists:
217
381
 
218
- sshTunnelProxy.on('error',(...args) => win.webContents.send('error',...args));
382
+ ```ts
383
+ import { SSHTunnelProxy } from 'ssh_tunnel_proxy';
219
384
 
220
- };
385
+ const proxy = new SSHTunnelProxy();
386
+ proxy.on('ssh_tunnel_ready', () => console.log('up'));
387
+ await proxy.connectSSH(
388
+ {
389
+ username: 'user',
390
+ host: '127.0.0.1',
391
+ port: '9191',
392
+ private_key_filename: '~/.ssh/id_ed25519',
393
+ proxy_ports: ['8123:127.0.0.1:8123'],
394
+ },
395
+ { 22: true, 80: true, 443: true },
396
+ );
221
397
  ```
222
398
 
223
- preload.js:
224
- ```js
225
- const { contextBridge, ipcRenderer } = require('electron')
226
-
227
- contextBridge.exposeInMainWorld('sshModule', {
228
- connect_ssh_sync: opts => ipcRenderer.sendSync('connect_ssh_sync', opts),
229
- generate_keypair: (...args) => ipcRenderer.sendSync("generate_keypair", ...args),
230
- get_public_key: (...args) => ipcRenderer.sendSync("get_public_key", ...args),
231
- network_online: (...args) => ipcRenderer.send("network_online", ...args),
232
- network_offline: (...args) => ipcRenderer.send("network_offline", ...args),
233
- ready: (callback) => ipcRenderer.on('ready', callback),
234
- debug: (callback) => ipcRenderer.on('debug', callback),
235
- error: (callback) => ipcRenderer.on('error', callback)
236
- });
399
+ `ngrok_api`, `service_name`, and `shell` throw. Details in
400
+ [MIGRATION.md](./MIGRATION.md).
401
+
402
+ ## Development
403
+
404
+ ```bash
405
+ npm install
406
+ npm run build
407
+ npm test
237
408
  ```
238
409
 
239
- // render process
240
- ```js
241
- // initialize ssh message handlers
242
- sshModule.ready((event, data) => {
243
- });
244
- sshModule.debug((event,msg)=>{
245
- })
246
- sshModule.error((event,err)=>{
247
- });
410
+ Integration tests run an in-process `ssh2.Server`. No external `sshd` or network
411
+ is required.
248
412
 
249
- // options for connect_ssh_sync
250
- var sshParams = {
251
- "username": "<username>",
252
- "password": "",
253
- "host": "",
254
- "port": "",
255
- "proxy_ports": [
256
- "8280:127.0.0.1:80",
257
- "9000:127.0.0.1:9000",
258
- "8122:192.168.2.1:22"
259
- ],
260
- "service_name": "service",
261
- "server_name": "server",
262
- "ngrok_api": "<ngrok_api_key>"
263
- };
264
-
265
- // initate ssh tunnel
266
- sshModule.connect_ssh_sync(sshParams);
267
- ```
413
+ ## License
414
+
415
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,14 @@
1
+ import type { Client } from 'ssh2';
2
+ import type { TunnelError } from './errors';
3
+ import type { ConnectionState, ConnectionStatus, ForwardState, ForwardStatus } from './model';
4
+ export interface TransportBridge {
5
+ /** The live SSH client, or throw TRANSPORT_NOT_READY. */
6
+ requireClient(forwardId: string): Client;
7
+ /** The live SSH client, or undefined when the transport is down. */
8
+ peekClient(): Client | undefined;
9
+ onConnectionChanged(status: ConnectionStatus, previous: ConnectionState): void;
10
+ onForwardChanged(status: ForwardStatus, previous: ForwardState): void;
11
+ onError(error: TunnelError): void;
12
+ debug(message: string, ...args: unknown[]): void;
13
+ }
14
+ //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACd,MAAM,SAAS,CAAC;AAEjB,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IACzC,oEAAoE;IACpE,UAAU,IAAI,MAAM,GAAG,SAAS,CAAC;IACjC,mBAAmB,CAAC,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,eAAe,GAAG,IAAI,CAAC;IAC/E,gBAAgB,CAAC,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IACtE,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IAClC,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAClD"}
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ /*
3
+ * bridge.ts — the narrow surface a forward handle needs from its tunnel.
4
+ *
5
+ * Forward handles never reach into the tunnel, and the tunnel never reaches into
6
+ * a handle's sockets. Everything crosses this interface, which keeps the forward
7
+ * logic unit-testable against a fake transport.
8
+ *
9
+ * License: MIT
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.js","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":";AAAA;;;;;;;;GAQG"}