Skip to content

Commit

Permalink
feat: add local and dynamic port forward methods
Browse files Browse the repository at this point in the history
  • Loading branch information
henrybarreto committed Oct 10, 2024
1 parent 93282fc commit 4013690
Show file tree
Hide file tree
Showing 10 changed files with 1,267 additions and 8 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v2
with:
node-version: '18'
node-version: '20'
- name: Install wine
if: ${{ matrix.platform == 'windows' }}
run: |
Expand Down
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"main": "./out/main/index.js",
"author": "ShellHub <[email protected]>(https://shellhub.io)",
"homepage": "https://github.com/shellhub-io/desktop",
"engines": {
"node": "^20"
},
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
Expand All @@ -17,7 +20,8 @@
"postinstall": "electron-builder install-app-deps",
"build:win": "npm run build && electron-builder --win --config",
"build:mac": "npm run build && electron-builder --mac --config",
"build:linux": "npm run build && electron-builder --linux --config"
"build:linux": "npm run build && electron-builder --linux --config",
"test": "vitest"
},
"dependencies": {
"@electron-toolkit/preload": "^2.0.0",
Expand All @@ -31,6 +35,8 @@
"electron-updater": "^6.1.1",
"pinia": "^2.1.7",
"sass": "^1.69.5",
"socksv5": "^0.0.6",
"ssh2": "^1.16.0",
"vee-validate": "^4.12.7",
"vue-router": "^4.2.5",
"vuetify": "^3.4.6",
Expand All @@ -42,7 +48,7 @@
"@electron-toolkit/eslint-config-ts": "^1.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@rushstack/eslint-patch": "^1.3.3",
"@types/node": "^18.17.5",
"@types/node": "^22.7.4",
"@vitejs/plugin-vue": "^4.3.1",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.3",
Expand All @@ -56,6 +62,7 @@
"typescript": "^5.1.6",
"vite": "^4.4.9",
"vite-plugin-vuetify": "^2.0.1",
"vitest": "^2.1.2",
"vue": "^3.3.4",
"vue-tsc": "^1.8.8"
}
Expand Down
9 changes: 9 additions & 0 deletions src/preload/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
export * from './ssh/index.d'

import { ElectronAPI } from '@electron-toolkit/preload'
import EventEmitter from 'events'

export interface SSH {
localPortForward(settings: any): SSHConnection
dynamicPortForward(settings: any): SSHConnection
}

declare global {
interface Window {
ssh: SSH
electron: ElectronAPI
api: unknown
}
Expand Down
40 changes: 40 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,54 @@
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import {
SSH,
SSHConnection,
SSHConnectionLocalPortForward,
SSHConnectionDynamicPortForward
} from './index.d'

// Custom APIs for renderer
const api = {}

const ssh: SSH = {
localPortForward: (settings: any): SSHConnection => {
const localPortForwardInstance = new SSHConnectionLocalPortForward(settings)

return {
events: localPortForwardInstance.events,
client: localPortForwardInstance.client,
connect: localPortForwardInstance.connect.bind(localPortForwardInstance),
disconnect: localPortForwardInstance.disconnect.bind(localPortForwardInstance),
onAuth: localPortForwardInstance.onAuth.bind(localPortForwardInstance),
onConnect: localPortForwardInstance.onConnect.bind(localPortForwardInstance),
onError: localPortForwardInstance.onError.bind(localPortForwardInstance),
onDisconnect: localPortForwardInstance.onDisconnect.bind(localPortForwardInstance)
}
},
dynamicPortForward: (settings: any): SSHConnection => {
const dynamicPortForwardInstance = new SSHConnectionDynamicPortForward(settings)

return {
events: dynamicPortForwardInstance.events,
client: dynamicPortForwardInstance.client,
connect: dynamicPortForwardInstance.connect.bind(dynamicPortForwardInstance),
disconnect: dynamicPortForwardInstance.disconnect.bind(dynamicPortForwardInstance),
onAuth: dynamicPortForwardInstance.onAuth.bind(dynamicPortForwardInstance),
onConnect: dynamicPortForwardInstance.onConnect.bind(dynamicPortForwardInstance),
onError: dynamicPortForwardInstance.onError.bind(dynamicPortForwardInstance),
onDisconnect: dynamicPortForwardInstance.onDisconnect.bind(dynamicPortForwardInstance)
}
}
}

// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('ssh', ssh)
} catch (error) {
console.error(error)
}
Expand All @@ -19,4 +57,6 @@ if (process.contextIsolated) {
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
// @ts-ignore (define in dts)
window.ssh = ssh
}
44 changes: 44 additions & 0 deletions src/preload/ssh/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import EventEmitter from 'node:events'
import ssh2 from 'ssh2'

export enum SSHEvent {
Auth = 'auth',
Connect = 'connect',
Error = 'error',
Disconnect = 'disconnect'
}

export class SSHEmitter extends EventEmitter {}

export type SSHConnectionAuth = {
host: string
username: string
password: string
namespace: string
device: string
}

export type SSHLocalPortForwardSettings = {
sourceAddr: string
sourcePort: number
destinationAddr: string
destinationPort: number
}

export type SSHDynamicPortForwardSettings = {
destinationAddr: string
destinationPort: number
}

export interface SSHConnection {
events: SSHEmitter
client: ssh2.Client
connect(auth: SSHConnectionAuth): void
disconnect(): void
onAuth(callback: any): void
onConnect(callback: any): void
onError(callback: any): void
onDisconnect(callback: any): void
}

export * from './ssh'
130 changes: 130 additions & 0 deletions src/preload/ssh/ssh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, it, beforeEach, vi, expect } from 'vitest'
import { SSHConnectionAuth } from './index.d'
import {
SSHConnectionPortForward,
SSHConnectionLocalPortForward,
SSHConnectionDynamicPortForward
} from './ssh'

import net from 'node:net'
import socks from 'socksv5'
import ssh2 from 'ssh2'

vi.mock('node:net')
vi.mock('socksv5')
vi.mock('ssh2')

let clientMock: any
let serverMock: any

beforeEach(() => {
clientMock = {
connect: vi.fn(),
end: vi.fn(),
forwardOut: vi.fn(),
on: vi.fn()
}

serverMock = {
listen: vi.fn(),
on: vi.fn(),
close: vi.fn(),
useAuth: vi.fn()
}

vi.spyOn(ssh2, 'Client').mockImplementation(() => clientMock)
vi.spyOn(net, 'createServer').mockReturnValue(serverMock as any)
vi.spyOn(socks, 'createServer').mockReturnValue(serverMock as any)
})

describe('SSHConnectionPortForward', () => {
it('should connect using provided auth', () => {
const sshConnection = new SSHConnectionPortForward({})
const auth: SSHConnectionAuth = {
host: 'localhost',
username: 'user',
password: 'pass',
namespace: 'namespace',
device: 'device'
}

sshConnection.connect(auth)

expect(clientMock.connect).toHaveBeenCalledWith({
host: 'localhost',
username: '[email protected]',
password: 'pass'
})
})

it('should emit error event on connection error', () => {
const sshConnection = new SSHConnectionPortForward({})

const errorCallback = vi.fn()
sshConnection.onError(errorCallback)

const error = new Error('Connection failed')
clientMock.connect.mockImplementation(() => {
throw error
})

sshConnection.connect({
host: 'localhost',
username: 'user',
password: 'pass',
namespace: 'ns',
device: 'dev'
})

expect(errorCallback).toHaveBeenCalledWith(error)
})
})

describe('SSHConnectionLocalPortForward', () => {
it('should start local port forwarding', () => {
const settings = {
sourceAddr: '127.0.0.1',
sourcePort: 8000,
destinationAddr: '192.168.1.10',
destinationPort: 8080
}

new SSHConnectionLocalPortForward(settings)
clientMock.on.mock.calls.find((call) => call[0] === 'ready')[1]()

expect(serverMock.listen).toHaveBeenCalledWith(
settings.sourcePort,
settings.sourceAddr,
expect.any(Function)
)
})
})

describe('SSHConnectionDynamicPortForward', () => {
it('should start dynamic port forwarding', () => {
const settings = {
destinationAddr: '127.0.0.1',
destinationPort: 1080
}

new SSHConnectionDynamicPortForward(settings)
clientMock.on.mock.calls.find((call) => call[0] === 'ready')[1]()

expect(serverMock.listen).toHaveBeenCalledWith(
settings.destinationPort,
settings.destinationAddr,
expect.any(Function)
)
})

it('should handle socks authentication', () => {
const settings = {
destinationAddr: '127.0.0.1',
destinationPort: 1080
}

new SSHConnectionDynamicPortForward(settings)

expect(serverMock.useAuth).toHaveBeenCalledWith(socks.auth.None())
})
})
Loading

0 comments on commit 4013690

Please sign in to comment.