Skip to content

Commit 852bfdb

Browse files
committed
feat: expose ssh port forward as event based API
1 parent 702a9e3 commit 852bfdb

File tree

4 files changed

+229
-115
lines changed

4 files changed

+229
-115
lines changed

package.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
"@vueuse/core": "^10.7.0",
3333
"axios": "^1.6.2",
3434
"electron-updater": "^6.1.1",
35-
"node-ssh": "^13.2.0",
3635
"pinia": "^2.1.7",
3736
"sass": "^1.69.5",
3837
"socksv5": "^0.0.6",
@@ -48,7 +47,7 @@
4847
"@electron-toolkit/eslint-config-ts": "^1.0.0",
4948
"@electron-toolkit/tsconfig": "^1.0.1",
5049
"@rushstack/eslint-patch": "^1.3.3",
51-
"@types/node": "^18.17.5",
50+
"@types/node": "^22.7.4",
5251
"@vitejs/plugin-vue": "^4.3.1",
5352
"@vue/eslint-config-prettier": "^8.0.0",
5453
"@vue/eslint-config-typescript": "^11.0.3",

src/preload/index.d.ts

+47
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,54 @@
11
import { ElectronAPI } from '@electron-toolkit/preload'
2+
import EventEmitter from 'events'
3+
4+
export enum SSHEvent {
5+
Auth = 'auth',
6+
Connect = 'connect',
7+
Error = 'error',
8+
Disconnect = 'disconnect'
9+
}
10+
11+
export class SSHEmitter extends EventEmitter {}
12+
13+
export type SSHConnectionAuth = {
14+
host: string
15+
username: string
16+
password: string
17+
namespace: string
18+
device: string
19+
}
20+
21+
export type SSHLocalPortForwardSettings = {
22+
sourceAddr: string
23+
sourcePort: number
24+
destinationAddr: string
25+
destinationPort: number
26+
}
27+
28+
export type SSHDynamicPortForwardSettings = {
29+
destinationAddr: string
30+
destinationPort: number
31+
}
32+
33+
export interface SSHConnection {
34+
eventEmitter: SSHEmitter
35+
client: ssh2.Client
36+
connect(auth: SSHConnectionAuth, settings: any): void
37+
disconnect(): void
38+
onAuth(callback: any): void
39+
onConnect(callback: any): void
40+
onError(callback: any): void
41+
onDisconnect(callback: any): void
42+
}
43+
44+
export interface SSH {
45+
localPortForward(): SSHConnection
46+
dynamicPortForward(): SSHConnection
47+
}
248

349
declare global {
450
interface Window {
51+
ssh: SSH
552
electron: ElectronAPI
653
api: unknown
754
}

src/preload/index.ts

+166-74
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,193 @@
11
import { contextBridge } from 'electron'
22
import { electronAPI } from '@electron-toolkit/preload'
3+
import { SSH, SSHConnection, SSHEvent, SSHEmitter, SSHConnectionAuth } from './index.d'
34
import net from 'net'
4-
import { Channel } from 'ssh2'
5-
import { NodeSSH } from 'node-ssh'
65
import socks from 'socksv5'
6+
import ssh2 from 'ssh2'
77

88
// Custom APIs for renderer
99
const api = {}
1010

11-
type SSHAuth = {
12-
host: string
13-
namespace: string
14-
device: string
15-
username: string
16-
password: string
17-
}
11+
export class SSHConnectionLocalPortForward implements SSHConnection {
12+
eventEmitter = new SSHEmitter()
13+
client = new ssh2.Client()
1814

19-
type SSHLocalPortForwardSettings = {
20-
sourceIP: string
21-
sourcePort: number
22-
destinationIP: string
23-
destinationPort: number
24-
}
15+
connect(auth: SSHConnectionAuth, settings: any) {
16+
this.client.on('ready', () => {
17+
console.info('SSH connection established')
18+
this.eventEmitter.emit(SSHEvent.Auth)
2519

26-
type SSHDynamicPortForwardSettings = {
27-
destinationIP: string
28-
destinationPort: number
29-
}
20+
this.client.forwardOut(
21+
settings.sourceAddr,
22+
settings.sourcePort,
23+
settings.destinationAddr,
24+
settings.destinationPort,
25+
(err, stream) => {
26+
if (err) {
27+
console.error('SSH forwarding error:', err)
28+
this.eventEmitter.emit(SSHEvent.Error, err)
29+
return
30+
}
3031

31-
const ssh = {
32-
localPortForward: (auth: SSHAuth, settings: SSHLocalPortForwardSettings) => {
33-
new NodeSSH()
34-
.connect({
35-
host: auth.host,
36-
username: `${auth.username}@${auth.namespace}.${auth.device}`,
37-
password: auth.password
38-
})
39-
.then((ssh: NodeSSH) => {
40-
console.info('SSH connection stablished')
41-
42-
return ssh.forwardOut(
43-
settings.sourceIP,
44-
settings.sourcePort,
45-
settings.destinationIP,
46-
settings.destinationPort
47-
)
48-
})
49-
.then((connection: Channel) => {
50-
console.info('Local port forward started')
32+
const server = net.createServer((client) => {
33+
client.pipe(stream).pipe(client)
34+
})
5135

52-
return net
53-
.createServer((client: net.Socket) => {
54-
client.pipe(connection).pipe(client)
36+
server.on('close', () => {
37+
this.eventEmitter.emit(SSHEvent.Disconnect)
5538
})
56-
.listen(settings.sourcePort, settings.sourceIP, () => {
39+
40+
server.listen(settings.sourcePort, settings.sourceAddr, () => {
5741
console.log(
58-
`Local port forward started from ${settings.sourceIP}:${settings.sourcePort} to ${settings.destinationIP}:${settings.destinationPort}.`
42+
`Local port forward started from ${settings.sourceAddr}:${settings.sourcePort} to ${settings.destinationAddr}:${settings.destinationPort}.`
5943
)
44+
this.eventEmitter.emit(SSHEvent.Connect, settings.sourcePort, settings.destinationAddr)
6045
})
61-
})
62-
.catch((e) => {
63-
console.error('SSH connection error:', e)
64-
})
65-
},
66-
dynamicPortForward: (auth: SSHAuth, settings: SSHDynamicPortForwardSettings) => {
67-
try {
68-
const client = new NodeSSH().connect({
69-
host: auth.host,
70-
username: `${auth.username}@${auth.namespace}.${auth.device}`,
71-
password: auth.password
72-
})
46+
}
47+
)
48+
})
49+
50+
this.client.on('error', (err) => {
51+
console.error('SSH connection error:', err)
52+
this.eventEmitter.emit(SSHEvent.Error, err)
53+
})
54+
55+
this.client.connect({
56+
host: auth.host,
57+
username: `${auth.username}@${auth.namespace}.${auth.device}`,
58+
password: auth.password
59+
})
60+
}
61+
62+
disconnect() {
63+
if (this.client) {
64+
this.client.end()
65+
this.eventEmitter.emit(SSHEvent.Disconnect)
66+
}
67+
}
68+
69+
onAuth(callback: any) {
70+
this.eventEmitter.on(SSHEvent.Auth, callback)
71+
}
72+
73+
onConnect(callback: any) {
74+
this.eventEmitter.on(SSHEvent.Connect, callback)
75+
}
76+
77+
onError(callback: any) {
78+
this.eventEmitter.on(SSHEvent.Error, callback)
79+
}
80+
81+
onDisconnect(callback: any) {
82+
this.eventEmitter.on(SSHEvent.Disconnect, callback)
83+
}
84+
}
85+
86+
export class SSHConnectionDynamicPortForward implements SSHConnection {
87+
eventEmitter = new SSHEmitter()
88+
client = new ssh2.Client()
89+
90+
connect(auth: SSHConnectionAuth, settings: any) {
91+
this.client.on('ready', () => {
92+
console.info('SSH connection established')
93+
this.eventEmitter.emit(SSHEvent.Auth)
94+
95+
const server = socks.createServer((info, accept, deny) => {
96+
this.client.forwardOut(
97+
info.srcAddr,
98+
info.srcPort,
99+
info.dstAddr,
100+
info.dstPort,
101+
(err, stream) => {
102+
if (err) {
103+
console.error('SSH forwarding error:', err)
104+
this.eventEmitter.emit(SSHEvent.Error, err)
105+
deny()
106+
return
107+
}
73108

74-
const server = socks.createServer((info, accept, _) => {
75-
client.then((connection: NodeSSH) => {
76-
connection
77-
.forwardOut(info.srcAddr, info.srcPort, info.dstAddr, info.dstPort)
78-
.then((channel: Channel) => {
79-
console.info(info)
80-
const client = accept(true)
81-
82-
channel.pipe(client).pipe(channel)
83-
})
84-
.catch((e) => {
85-
console.error(e)
86-
})
87-
})
109+
const client = accept(true)
110+
stream.pipe(client).pipe(stream)
111+
}
112+
)
88113
})
89114

90115
server.on('error', (err) => {
91116
console.log('Server error:', err)
117+
this.eventEmitter.emit(SSHEvent.Error, err)
92118
})
93119

94120
server.useAuth(socks.auth.None())
95121

96-
server.listen(settings.destinationPort, settings.destinationIP, () => {
97-
console.log('Server listening on', settings.destinationPort, settings.destinationIP)
122+
server.listen(settings.destinationPort, settings.destinationAddr, () => {
123+
console.log('Server listening on', settings.destinationPort, settings.destinationAddr)
124+
this.eventEmitter.emit(SSHEvent.Connect, settings.destinationPort, settings.destinationAddr)
98125
})
99-
} catch (e) {
100-
console.log('Failed to create server', e)
126+
})
127+
128+
this.client.on('error', (err) => {
129+
console.error('SSH connection error:', err)
130+
this.eventEmitter.emit(SSHEvent.Error, err)
131+
})
132+
133+
this.client.connect({
134+
host: auth.host,
135+
username: `${auth.username}@${auth.namespace}.${auth.device}`,
136+
password: auth.password
137+
})
138+
}
139+
140+
disconnect() {
141+
if (this.client) {
142+
this.client.end()
143+
this.eventEmitter.emit(SSHEvent.Disconnect)
144+
}
145+
}
146+
147+
onAuth(callback: any) {
148+
this.eventEmitter.on(SSHEvent.Auth, callback)
149+
}
150+
151+
onConnect(callback: any) {
152+
this.eventEmitter.on(SSHEvent.Connect, callback)
153+
}
154+
155+
onError(callback: any) {
156+
this.eventEmitter.on(SSHEvent.Error, callback)
157+
}
158+
159+
onDisconnect(callback: any) {
160+
this.eventEmitter.on(SSHEvent.Disconnect, callback)
161+
}
162+
}
163+
164+
const ssh: SSH = {
165+
localPortForward: (): SSHConnection => {
166+
const localPortForwardInstance = new SSHConnectionLocalPortForward()
167+
168+
return {
169+
eventEmitter: localPortForwardInstance.eventEmitter,
170+
client: localPortForwardInstance.client,
171+
connect: localPortForwardInstance.connect.bind(this),
172+
disconnect: localPortForwardInstance.disconnect.bind(this),
173+
onAuth: localPortForwardInstance.onAuth.bind(this),
174+
onConnect: localPortForwardInstance.onConnect.bind(this),
175+
onError: localPortForwardInstance.onError.bind(this),
176+
onDisconnect: localPortForwardInstance.onDisconnect.bind(this)
177+
}
178+
},
179+
dynamicPortForward: (): SSHConnection => {
180+
const dynamicPortForwardInstance = new SSHConnectionDynamicPortForward()
181+
182+
return {
183+
eventEmitter: dynamicPortForwardInstance.eventEmitter,
184+
client: dynamicPortForwardInstance.client,
185+
connect: dynamicPortForwardInstance.connect.bind(this),
186+
disconnect: dynamicPortForwardInstance.disconnect.bind(this),
187+
onAuth: dynamicPortForwardInstance.onAuth.bind(this),
188+
onConnect: dynamicPortForwardInstance.onConnect.bind(this),
189+
onError: dynamicPortForwardInstance.onError.bind(this),
190+
onDisconnect: dynamicPortForwardInstance.onDisconnect.bind(this)
101191
}
102192
}
103193
}
@@ -118,4 +208,6 @@ if (process.contextIsolated) {
118208
window.electron = electronAPI
119209
// @ts-ignore (define in dts)
120210
window.api = api
211+
// @ts-ignore (define in dts)
212+
window.ssh = ssh
121213
}

0 commit comments

Comments
 (0)