Skip to content

Commit edf3d3a

Browse files
committed
feat: add local and dynamic port forward methods
1 parent 48c7c43 commit edf3d3a

File tree

10 files changed

+1267
-8
lines changed

10 files changed

+1267
-8
lines changed

.github/workflows/build.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
- name: Install Node.js, NPM and Yarn
3333
uses: actions/setup-node@v2
3434
with:
35-
node-version: '18'
35+
node-version: '20'
3636
- name: Install wine
3737
if: ${{ matrix.platform == 'windows' }}
3838
run: |

package.json

+9-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"main": "./out/main/index.js",
66
"author": "ShellHub <[email protected]>(https://shellhub.io)",
77
"homepage": "https://github.com/shellhub-io/desktop",
8+
"engines": {
9+
"node": "^20"
10+
},
811
"scripts": {
912
"format": "prettier --write .",
1013
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
@@ -17,7 +20,8 @@
1720
"postinstall": "electron-builder install-app-deps",
1821
"build:win": "npm run build && electron-builder --win --config",
1922
"build:mac": "npm run build && electron-builder --mac --config",
20-
"build:linux": "npm run build && electron-builder --linux --config"
23+
"build:linux": "npm run build && electron-builder --linux --config",
24+
"test": "vitest"
2125
},
2226
"dependencies": {
2327
"@electron-toolkit/preload": "^2.0.0",
@@ -31,6 +35,8 @@
3135
"electron-updater": "^6.1.1",
3236
"pinia": "^2.1.7",
3337
"sass": "^1.69.5",
38+
"socksv5": "^0.0.6",
39+
"ssh2": "^1.16.0",
3440
"vee-validate": "^4.12.7",
3541
"vue-router": "^4.2.5",
3642
"vuetify": "^3.4.6",
@@ -42,7 +48,7 @@
4248
"@electron-toolkit/eslint-config-ts": "^1.0.0",
4349
"@electron-toolkit/tsconfig": "^1.0.1",
4450
"@rushstack/eslint-patch": "^1.3.3",
45-
"@types/node": "^18.17.5",
51+
"@types/node": "^22.7.4",
4652
"@vitejs/plugin-vue": "^4.3.1",
4753
"@vue/eslint-config-prettier": "^8.0.0",
4854
"@vue/eslint-config-typescript": "^11.0.3",
@@ -56,6 +62,7 @@
5662
"typescript": "^5.1.6",
5763
"vite": "^4.4.9",
5864
"vite-plugin-vuetify": "^2.0.1",
65+
"vitest": "^2.1.2",
5966
"vue": "^3.3.4",
6067
"vue-tsc": "^1.8.8"
6168
}

src/preload/index.d.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
export * from './ssh/index.d'
2+
13
import { ElectronAPI } from '@electron-toolkit/preload'
4+
import EventEmitter from 'events'
5+
6+
export interface SSH {
7+
localPortForward(settings: any): SSHConnection
8+
dynamicPortForward(settings: any): SSHConnection
9+
}
210

311
declare global {
412
interface Window {
13+
ssh: SSH
514
electron: ElectronAPI
615
api: unknown
716
}

src/preload/index.ts

+40
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,54 @@
11
import { contextBridge } from 'electron'
22
import { electronAPI } from '@electron-toolkit/preload'
3+
import {
4+
SSH,
5+
SSHConnection,
6+
SSHConnectionLocalPortForward,
7+
SSHConnectionDynamicPortForward
8+
} from './index.d'
39

410
// Custom APIs for renderer
511
const api = {}
612

13+
const ssh: SSH = {
14+
localPortForward: (settings: any): SSHConnection => {
15+
const localPortForwardInstance = new SSHConnectionLocalPortForward(settings)
16+
17+
return {
18+
events: localPortForwardInstance.events,
19+
client: localPortForwardInstance.client,
20+
connect: localPortForwardInstance.connect.bind(localPortForwardInstance),
21+
disconnect: localPortForwardInstance.disconnect.bind(localPortForwardInstance),
22+
onAuth: localPortForwardInstance.onAuth.bind(localPortForwardInstance),
23+
onConnect: localPortForwardInstance.onConnect.bind(localPortForwardInstance),
24+
onError: localPortForwardInstance.onError.bind(localPortForwardInstance),
25+
onDisconnect: localPortForwardInstance.onDisconnect.bind(localPortForwardInstance)
26+
}
27+
},
28+
dynamicPortForward: (settings: any): SSHConnection => {
29+
const dynamicPortForwardInstance = new SSHConnectionDynamicPortForward(settings)
30+
31+
return {
32+
events: dynamicPortForwardInstance.events,
33+
client: dynamicPortForwardInstance.client,
34+
connect: dynamicPortForwardInstance.connect.bind(dynamicPortForwardInstance),
35+
disconnect: dynamicPortForwardInstance.disconnect.bind(dynamicPortForwardInstance),
36+
onAuth: dynamicPortForwardInstance.onAuth.bind(dynamicPortForwardInstance),
37+
onConnect: dynamicPortForwardInstance.onConnect.bind(dynamicPortForwardInstance),
38+
onError: dynamicPortForwardInstance.onError.bind(dynamicPortForwardInstance),
39+
onDisconnect: dynamicPortForwardInstance.onDisconnect.bind(dynamicPortForwardInstance)
40+
}
41+
}
42+
}
43+
744
// Use `contextBridge` APIs to expose Electron APIs to
845
// renderer only if context isolation is enabled, otherwise
946
// just add to the DOM global.
1047
if (process.contextIsolated) {
1148
try {
1249
contextBridge.exposeInMainWorld('electron', electronAPI)
1350
contextBridge.exposeInMainWorld('api', api)
51+
contextBridge.exposeInMainWorld('ssh', ssh)
1452
} catch (error) {
1553
console.error(error)
1654
}
@@ -19,4 +57,6 @@ if (process.contextIsolated) {
1957
window.electron = electronAPI
2058
// @ts-ignore (define in dts)
2159
window.api = api
60+
// @ts-ignore (define in dts)
61+
window.ssh = ssh
2262
}

src/preload/ssh/index.d.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import EventEmitter from 'node:events'
2+
import ssh2 from 'ssh2'
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+
events: SSHEmitter
35+
client: ssh2.Client
36+
connect(auth: SSHConnectionAuth): 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 * from './ssh'

src/preload/ssh/ssh.test.ts

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, it, beforeEach, vi, expect } from 'vitest'
2+
import {
3+
SSHConnectionPortForward,
4+
SSHConnectionLocalPortForward,
5+
SSHConnectionDynamicPortForward
6+
} from './ssh'
7+
import { SSHEmitter, SSHEvent, SSHConnectionAuth } from './index.d'
8+
import net from 'node:net'
9+
import socks from 'socksv5'
10+
import ssh2 from 'ssh2'
11+
12+
vi.mock('node:net')
13+
vi.mock('socksv5')
14+
vi.mock('ssh2')
15+
16+
let sshClientMock: any
17+
let sshEmitterMock: any
18+
let serverMock: any
19+
20+
beforeEach(() => {
21+
sshClientMock = {
22+
connect: vi.fn(),
23+
end: vi.fn(),
24+
forwardOut: vi.fn(),
25+
on: vi.fn()
26+
}
27+
28+
sshEmitterMock = new SSHEmitter()
29+
30+
serverMock = {
31+
listen: vi.fn(),
32+
on: vi.fn(),
33+
close: vi.fn(),
34+
useAuth: vi.fn()
35+
}
36+
37+
vi.spyOn(ssh2, 'Client').mockImplementation(() => sshClientMock)
38+
vi.spyOn(net, 'createServer').mockReturnValue(serverMock as any)
39+
vi.spyOn(socks, 'createServer').mockReturnValue(serverMock as any)
40+
})
41+
42+
describe('SSHConnectionPortForward', () => {
43+
it('should connect using provided auth', () => {
44+
const sshConnection = new SSHConnectionPortForward({})
45+
const auth: SSHConnectionAuth = {
46+
host: 'localhost',
47+
username: 'user',
48+
password: 'pass',
49+
namespace: 'ns',
50+
device: 'dev'
51+
}
52+
53+
sshConnection.connect(auth)
54+
expect(sshClientMock.connect).toHaveBeenCalledWith({
55+
host: 'localhost',
56+
username: '[email protected]',
57+
password: 'pass'
58+
})
59+
})
60+
61+
it('should emit error event on connection error', () => {
62+
const sshConnection = new SSHConnectionPortForward({})
63+
const errorCallback = vi.fn()
64+
sshConnection.onError(errorCallback)
65+
66+
const error = new Error('Connection failed')
67+
sshClientMock.connect.mockImplementation(() => {
68+
throw error
69+
})
70+
71+
sshConnection.connect({
72+
host: 'localhost',
73+
username: 'user',
74+
password: 'pass',
75+
namespace: 'ns',
76+
device: 'dev'
77+
})
78+
79+
expect(errorCallback).toHaveBeenCalledWith(error)
80+
})
81+
})
82+
83+
describe('SSHConnectionLocalPortForward', () => {
84+
it('should start local port forwarding', () => {
85+
const settings = {
86+
sourceAddr: '127.0.0.1',
87+
sourcePort: 8000,
88+
destinationAddr: '192.168.1.10',
89+
destinationPort: 8080
90+
}
91+
92+
const sshConnection = new SSHConnectionLocalPortForward(settings)
93+
sshClientMock.on.mock.calls.find((call) => call[0] === 'ready')[1]()
94+
95+
expect(serverMock.listen).toHaveBeenCalledWith(
96+
settings.sourcePort,
97+
settings.sourceAddr,
98+
expect.any(Function)
99+
)
100+
})
101+
})
102+
103+
describe('SSHConnectionDynamicPortForward', () => {
104+
it('should start dynamic port forwarding', () => {
105+
const settings = {
106+
destinationAddr: '127.0.0.1',
107+
destinationPort: 1080
108+
}
109+
110+
const sshConnection = new SSHConnectionDynamicPortForward(settings)
111+
sshClientMock.on.mock.calls.find((call) => call[0] === 'ready')[1]()
112+
113+
expect(serverMock.listen).toHaveBeenCalledWith(
114+
settings.destinationPort,
115+
settings.destinationAddr,
116+
expect.any(Function)
117+
)
118+
})
119+
120+
it('should handle socks authentication', () => {
121+
const settings = {
122+
destinationAddr: '127.0.0.1',
123+
destinationPort: 1080
124+
}
125+
126+
new SSHConnectionDynamicPortForward(settings)
127+
128+
expect(serverMock.useAuth).toHaveBeenCalledWith(socks.auth.None())
129+
})
130+
})

0 commit comments

Comments
 (0)