Skip to content

Commit 2d9f8df

Browse files
committed
Add: experimental tunnelOut function
1 parent f108664 commit 2d9f8df

File tree

3 files changed

+273
-2
lines changed

3 files changed

+273
-2
lines changed

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hivessh",
3-
"version": "1.3.2",
3+
"version": "1.4.1",
44
"description": "HiveSsh is an innovative library designed to streamline SSH2 connections and simplify task execution on Linux servers.",
55
"type": "module",
66
"main": "./dist/index.cjs",
@@ -68,4 +68,4 @@
6868
"dependencies": {
6969
"ssh2": "^1.15.0"
7070
}
71-
}
71+
}

src/SshHost.ts

+25
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import net from "net"
12
import { ClientChannel, ClientErrorExtensions, SFTPWrapper, Client as SshClient } from "ssh2"
23
import { ExecSession } from "./ExecSession.js"
34
import { handleHops } from "./HostHop.js"
45
import { CmdChannelOptions, CmdExecOptions, SshChannel, SshChannelExit, execSshChannel } from "./SshExec.js"
56
import { SshHostOptions, SshHostSettings, loadSettings } from "./SshHostOptions.js"
7+
import { SshTunnelOutOptions, tunnelOut } from "./SshTunnel.js"
68
import { AbstractPackageManager, getApm } from "./apm/apm.js"
79
import { OsRelease, fetchOsRelease } from "./essentials/OsRelease.js"
810
import { SFTPPromiseWrapper, createSFTPPromiseWrapper } from "./essentials/SftpPromiseWrapper.js"
@@ -279,6 +281,8 @@ export class SshHost {
279281
})
280282
}
281283

284+
285+
282286
cachedApm: AbstractPackageManager | undefined
283287

284288
/**
@@ -306,4 +310,25 @@ export class SshHost {
306310
return this.cachedApm = apm
307311
})
308312
}
313+
314+
/**
315+
* @experimental This function is experimental and may not be stable.
316+
* Creates a local server that tunnels incoming connections to a remote linux socket or host and port bind.
317+
*
318+
* You need to close the server to stop tunneling!
319+
*
320+
* This function creates a server that listens for incoming connections and forwards them to the remote SSH host.
321+
*
322+
* @param tunnelOptions - Options specifying remote linux socket or host and port details.
323+
*
324+
* @returns A promise that resolves to the created server that need to be closed.
325+
*/
326+
tunnelOut(
327+
tunnelOptions: SshTunnelOutOptions,
328+
): Promise<net.Server> {
329+
return tunnelOut(
330+
this.ssh,
331+
tunnelOptions
332+
)
333+
}
309334
}

src/SshTunnel.ts

+246
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import net, { type ListenOptions as ServerOptions } from "net";
2+
import { Client } from "ssh2";
3+
4+
export type LocalHostPortExtraOptions =
5+
Omit<
6+
Partial<ServerOptions>,
7+
"host" | "port" | "path"
8+
>
9+
10+
export type SshTunnelOutLocalHostPort = {
11+
localHost: string,
12+
localPort: number,
13+
} & LocalHostPortExtraOptions
14+
15+
export type SshTunnelOutLocalSocket = {
16+
localPath: string,
17+
} & LocalHostPortExtraOptions
18+
19+
export interface SshTunnelOutRemoteHostPort {
20+
remoteHost: string,
21+
remotePort: number,
22+
forwardSourceHost?: string
23+
forwardSourcePort?: number
24+
}
25+
26+
export interface SshTunnelOutRemoteSocket {
27+
remotePath: string,
28+
}
29+
30+
export type SshTunnelLocalOutOptions =
31+
SshTunnelOutLocalHostPort |
32+
SshTunnelOutLocalSocket
33+
34+
export type SshTunnelRemoteOutOptions =
35+
SshTunnelOutRemoteHostPort |
36+
SshTunnelOutRemoteSocket
37+
38+
export type SshTunnelOutOptions =
39+
SshTunnelLocalOutOptions &
40+
SshTunnelRemoteOutOptions
41+
42+
/**
43+
* Returns true if the given options are for a local socket (i.e. path is present).
44+
*
45+
* @param options - The options to check.
46+
* @returns True if the options are for a local socket, otherwise false.
47+
*/
48+
export function isSshTunnelOutLocalSocketOption(
49+
options: SshTunnelOutOptions,
50+
): options is SshTunnelOutLocalSocket & SshTunnelRemoteOutOptions {
51+
return typeof options == "object" &&
52+
options != null &&
53+
typeof (options as any).localPath == "string"
54+
}
55+
56+
/**
57+
* Returns true if the given options are for a remote socket (i.e. remotePath is present).
58+
*
59+
* @param options - The options to check.
60+
* @returns True if the options are for a remote socket, otherwise false.
61+
*/
62+
export function isSshTunnelOutRemoteSocketOption(
63+
options: SshTunnelOutOptions,
64+
): options is SshTunnelLocalOutOptions & SshTunnelOutRemoteSocket {
65+
return typeof options == "object" &&
66+
options != null &&
67+
typeof (options as any).remotePath == "string"
68+
}
69+
70+
/**
71+
* @experimental This function is experimental and may not be stable.
72+
* Tunnels incoming server socket conntions to a remote host and port bind (not linux socket).
73+
*
74+
* This function establishes an SSH tunnel by forwarding
75+
* a local server's address and port to a specified remote
76+
* host and port. It manages the connection and lifecycle of
77+
* the tunnel, ensuring that resources are cleaned up upon
78+
* closure of the server or socket.
79+
*
80+
* @param sshClient - The SSH client instance used to establish the tunnel.
81+
* @param server - The local server for which the tunnel is being created.
82+
* @param socket - The socket associated with the server.
83+
* @param tunnelOptions - Options specifying remote host and port details
84+
* and optional forwarding source host and port.
85+
*
86+
* @throws {Error} If the server's socket address is invalid.
87+
*/
88+
export function handleRemoteHostPortOutTunnel(
89+
sshClient: Client,
90+
server: net.Server,
91+
socket: net.Socket,
92+
tunnelOptions: SshTunnelOutRemoteHostPort
93+
) {
94+
const address = server.address() as net.AddressInfo
95+
if (
96+
typeof address.address != "string" ||
97+
typeof address.family != "string" ||
98+
typeof address.port != "number"
99+
) {
100+
throw new Error(
101+
"Invalid server socket address: " +
102+
JSON.stringify(address, null, 2)
103+
)
104+
}
105+
106+
sshClient.forwardOut(
107+
tunnelOptions.forwardSourceHost ??
108+
address.address,
109+
tunnelOptions.forwardSourcePort ??
110+
address.port,
111+
tunnelOptions.remoteHost,
112+
tunnelOptions.remotePort, (err, clientChannel) => {
113+
if (err) {
114+
server.emit("error", err)
115+
return
116+
}
117+
118+
server.on('close', () => {
119+
if (!clientChannel.closed) {
120+
clientChannel.end()
121+
}
122+
if (!socket.closed) {
123+
socket.end()
124+
}
125+
})
126+
socket.pipe(clientChannel).pipe(socket)
127+
})
128+
}
129+
130+
/**
131+
* @experimental This function is experimental and may not be stable.
132+
* Tunnels incoming server socket conntions to a remote socket (not address and port bind) using openssh.
133+
*
134+
* This function establishes an SSH tunnel by forwarding
135+
* a local server's address and port to a specified remote
136+
* socket path. It manages the connection and lifecycle of
137+
* the tunnel, ensuring that resources are cleaned up upon
138+
* closure of the server or socket.
139+
*
140+
* @param sshClient - The SSH client instance used to establish the tunnel.
141+
* @param server - The local server for which the tunnel is being created.
142+
* @param socket - The socket associated with the server.
143+
* @param tunnelOptions - Options specifying remote socket path details
144+
* and optional forwarding source host and port.
145+
*
146+
* @throws {Error} If the server's socket address is invalid.
147+
*/
148+
export function handleRemoteSocketOutTunnel(
149+
sshClient: Client,
150+
server: net.Server,
151+
socket: net.Socket,
152+
tunnelOptions: SshTunnelOutRemoteSocket
153+
) {
154+
const address = server.address() as net.AddressInfo
155+
if (
156+
typeof address.address != "string" ||
157+
typeof address.family != "string" ||
158+
typeof address.port != "number"
159+
) {
160+
throw new Error(
161+
"Invalid server socket address: " +
162+
JSON.stringify(address, null, 2)
163+
)
164+
}
165+
166+
sshClient.openssh_forwardOutStreamLocal(
167+
tunnelOptions.remotePath,
168+
(err, clientChannel) => {
169+
if (err) {
170+
server.emit("error", err)
171+
return
172+
}
173+
174+
server.on('close', () => {
175+
if (!clientChannel.closed) {
176+
clientChannel.end()
177+
}
178+
if (!socket.closed) {
179+
socket.end()
180+
}
181+
})
182+
socket.pipe(clientChannel).pipe(socket)
183+
}
184+
)
185+
}
186+
187+
/**
188+
* @experimental This function is experimental and may not be stable.
189+
* Creates a local server that tunnels incoming connections to a remote linux socket or host and port bind.
190+
*
191+
* You need to close the server to stop tunneling!
192+
*
193+
* This function creates a server that listens for incoming connections and forwards them to the remote SSH host.
194+
*
195+
* @param sshClient - The SSH client instance used to establish the tunnel.
196+
* @param tunnelOptions - Options specifying remote linux socket or host and port details.
197+
*
198+
* @returns A promise that resolves to the created server that need to be closed.
199+
*/
200+
export function tunnelOut(
201+
sshClient: Client,
202+
tunnelOptions: SshTunnelOutOptions,
203+
): Promise<net.Server> {
204+
return new Promise<net.Server>((res, rej) => {
205+
let server: net.Server
206+
207+
server = net.createServer()
208+
server.on('error', (err) => {
209+
server.close()
210+
rej(err)
211+
})
212+
213+
server.on('connection',
214+
isSshTunnelOutRemoteSocketOption(tunnelOptions) ?
215+
(socket) => handleRemoteSocketOutTunnel(
216+
sshClient,
217+
server,
218+
socket,
219+
tunnelOptions,
220+
) :
221+
(socket) => handleRemoteHostPortOutTunnel(
222+
sshClient,
223+
server,
224+
socket,
225+
tunnelOptions,
226+
)
227+
)
228+
229+
const baseTunnelServerOptions =
230+
isSshTunnelOutLocalSocketOption(tunnelOptions) ?
231+
{
232+
path: tunnelOptions.localPath,
233+
} :
234+
{
235+
host: tunnelOptions.localHost,
236+
port: tunnelOptions.localPort,
237+
}
238+
239+
server.listen({
240+
...tunnelOptions,
241+
...baseTunnelServerOptions,
242+
})
243+
244+
res(server)
245+
})
246+
}

0 commit comments

Comments
 (0)