Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add local and dynamic port forward methods #4

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ jobs:
platform: [linux, windows, macos]
include:
- platform: linux
os: ubuntu-latest
os: ubuntu-24.04
target: AppImage
extension: AppImage
- platform: windows
os: ubuntu-latest
os: ubuntu-24.04
target: nsis
extension: exe
- platform: macos
Expand All @@ -32,16 +32,16 @@ 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: |
sudo dpkg --add-architecture i386
wget -nc https://dl.winehq.org/wine-builds/winehq.key
sudo apt-key add winehq.key
sudo add-apt-repository 'deb https://dl.winehq.org/wine-builds/ubuntu/ focal main'
sudo add-apt-repository 'deb https://dl.winehq.org/wine-builds/ubuntu/ noble main'
sudo apt-get update
sudo apt install -y winehq-stable
sudo apt install -y winehq-stable || sudo apt install -y wine
- name: Build
env:
PLATFORM: ${{ matrix.platform }}
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
}
51 changes: 51 additions & 0 deletions src/preload/ssh/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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 SSHPassword = {
password: string
}

export type SSHPrivateKey = {
privateKey: string | Buffer
}

export type SSHConnectionCreds = {
host: string
username: 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(creds: SSHConnectionCreds, auth: SSHPassword | SSHPrivateKey): void
disconnect(): void
onAuth(callback: any): void
onConnect(callback: any): void
onError(callback: any): void
onDisconnect(callback: any): void
}

export * from './ssh'
135 changes: 135 additions & 0 deletions src/preload/ssh/ssh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, beforeEach, vi, expect } from 'vitest'
import { SSHConnectionCreds } 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 creds: SSHConnectionCreds = {
host: 'localhost',
username: 'user',
namespace: 'namespace',
device: 'device'
}

sshConnection.connect(creds, {
password: 'pass'
})

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',
namespace: 'ns',
device: 'dev'
},
{
password: 'pass'
}
)

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
Loading