diff --git a/package.json b/package.json index 955301f..2c47e22 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "@vueuse/core": "^10.7.0", "axios": "^1.6.2", "electron-updater": "^6.1.1", - "node-ssh": "^13.2.0", "pinia": "^2.1.7", "sass": "^1.69.5", "socksv5": "^0.0.6", @@ -48,7 +47,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", diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a153669..5faa68c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,7 +1,210 @@ import { ElectronAPI } from '@electron-toolkit/preload' +import EventEmitter from 'events' +import net from 'net' +import socks from 'socksv5' +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 { + eventEmitter: SSHEmitter + client: ssh2.Client + connect(auth: SSHConnectionAuth, settings: any): void + disconnect(): void + onAuth(callback: any): void + onListen(callback: any): void + onError(callback: any): void + onDisconnect(callback: any): void +} + +export class SSHConnectionLocalPortForward implements SSHConnection { + eventEmitter = new SSHEmitter() + client = new ssh2.Client() + + connect(auth: SSHConnectionAuth, settings: any) { + this.client.on('ready', () => { + console.info('SSH connection established') + this.eventEmitter.emit(SSHEvent.Auth) + + this.client.forwardOut( + settings.sourceAddr, + settings.sourcePort, + settings.destinationAddr, + settings.destinationPort, + (err, stream) => { + if (err) { + console.error('SSH forwarding error:', err) + this.eventEmitter.emit(SSHEvent.Error, err) + return + } + + const server = net.createServer((client) => { + client.pipe(stream).pipe(client) + }) + + server.on('close', () => { + this.eventEmitter.emit(SSHEvent.Disconnect) + }) + + server.listen(settings.sourcePort, settings.sourceAddr, () => { + console.log( + `Local port forward started from ${settings.sourceAddr}:${settings.sourcePort} to ${settings.destinationAddr}:${settings.destinationPort}.` + ) + this.eventEmitter.emit(SSHEvent.Connect, settings.sourcePort, settings.destinationAddr) + }) + } + ) + }) + + this.client.on('error', (err) => { + console.error('SSH connection error:', err) + this.eventEmitter.emit(SSHEvent.Error, err) + }) + + this.client.connect({ + host: auth.host, + username: `${auth.username}@${auth.namespace}.${auth.device}`, + password: auth.password + }) + } + + disconnect() { + if (this.client) { + this.client.end() + this.eventEmitter.emit(SSHEvent.Disconnect) + } + } + + onAuth(callback: any) { + this.eventEmitter.on(SSHEvent.Auth, callback) + } + + onConnect(callback: any) { + this.eventEmitter.on(SSHEvent.Connect, callback) + } + + onError(callback: any) { + this.eventEmitter.on(SSHEvent.Error, callback) + } + + onDisconnect(callback: any) { + this.eventEmitter.on(SSHEvent.Disconnect, callback) + } +} + +export class SSHConnectionDynamicPortForward implements SSHConnection { + eventEmitter = new SSHEmitter() + client = new ssh2.Client() + + connect(auth: SSHConnectionAuth, settings: any) { + this.client.on('ready', () => { + console.info('SSH connection established') + this.eventEmitter.emit(SSHEvent.Auth) + + const server = socks.createServer((info, accept, deny) => { + this.client.forwardOut( + info.srcAddr, + info.srcPort, + info.dstAddr, + info.dstPort, + (err, stream) => { + if (err) { + console.error('SSH forwarding error:', err) + this.eventEmitter.emit(SSHEvent.Error, err) + deny() + return + } + + const client = accept(true) + stream.pipe(client).pipe(stream) + } + ) + }) + + server.on('error', (err) => { + console.log('Server error:', err) + this.eventEmitter.emit(SSHEvent.Error, err) + }) + + server.useAuth(socks.auth.None()) + + server.listen(settings.destinationPort, settings.destinationAddr, () => { + console.log('Server listening on', settings.destinationPort, settings.destinationAddr) + this.eventEmitter.emit(SSHEvent.Connect, settings.destinationPort, settings.destinationAddr) + }) + }) + + this.client.on('error', (err) => { + console.error('SSH connection error:', err) + this.eventEmitter.emit(SSHEvent.Error, err) + }) + + this.client.connect({ + host: auth.host, + username: `${auth.username}@${auth.namespace}.${auth.device}`, + password: auth.password + }) + } + + disconnect() { + if (this.client) { + this.client.end() + this.eventEmitter.emit(SSHEvent.Disconnect) + } + } + + onAuth(callback: any) { + this.eventEmitter.on(SSHEvent.Auth, callback) + } + + onConnect(callback: any) { + this.eventEmitter.on(SSHEvent.Connect, callback) + } + + onError(callback: any) { + this.eventEmitter.on(SSHEvent.Error, callback) + } + + onDisconnect(callback: any) { + this.eventEmitter.on(SSHEvent.Disconnect, callback) + } +} + +export interface SSH { + localPortForward(): SSHConnection + dynamicPortForward(): SSHConnection +} declare global { interface Window { + ssh: SSH electron: ElectronAPI api: unknown } diff --git a/src/preload/index.ts b/src/preload/index.ts index f1ad743..de30d13 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,104 +1,47 @@ import { contextBridge } from 'electron' import { electronAPI } from '@electron-toolkit/preload' -import net from 'net' -import { Channel } from 'ssh2' -import { NodeSSH } from 'node-ssh' -import socks from 'socksv5' +import { + SSH, + SSHConnection, + SSHConnectionLocalPortForward, + SSHConnectionDynamicPortForward +} from './index.d' // Custom APIs for renderer const api = {} -type SSHAuth = { - host: string - namespace: string - device: string - username: string - password: string -} - -type SSHLocalPortForwardSettings = { - sourceIP: string - sourcePort: number - destinationIP: string - destinationPort: number -} +const ssh: SSH = { + localPortForward: (): SSHConnection => { + const localPortForwardInstance = new SSHConnectionLocalPortForward() -type SSHDynamicPortForwardSettings = { - destinationIP: string - destinationPort: number -} - -const ssh = { - localPortForward: (auth: SSHAuth, settings: SSHLocalPortForwardSettings) => { - new NodeSSH() - .connect({ - host: auth.host, - username: `${auth.username}@${auth.namespace}.${auth.device}`, - password: auth.password - }) - .then((ssh: NodeSSH) => { - console.info('SSH connection stablished') - - return ssh.forwardOut( - settings.sourceIP, - settings.sourcePort, - settings.destinationIP, - settings.destinationPort - ) - }) - .then((connection: Channel) => { - console.info('Local port forward started') + const localPortForwardObject: SSHConnection = { + eventEmitter: localPortForwardInstance.eventEmitter, + 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) + } - return net - .createServer((client: net.Socket) => { - client.pipe(connection).pipe(client) - }) - .listen(settings.sourcePort, settings.sourceIP, () => { - console.log( - `Local port forward started from ${settings.sourceIP}:${settings.sourcePort} to ${settings.destinationIP}:${settings.destinationPort}.` - ) - }) - }) - .catch((e) => { - console.error('SSH connection error:', e) - }) + return localPortForwardObject }, - dynamicPortForward: (auth: SSHAuth, settings: SSHDynamicPortForwardSettings) => { - try { - const client = new NodeSSH().connect({ - host: auth.host, - username: `${auth.username}@${auth.namespace}.${auth.device}`, - password: auth.password - }) + dynamicPortForward: (): SSHConnection => { + const dynamicPortForwardInstance = new SSHConnectionDynamicPortForward() - const server = socks.createServer((info, accept, _) => { - client.then((connection: NodeSSH) => { - connection - .forwardOut(info.srcAddr, info.srcPort, info.dstAddr, info.dstPort) - .then((channel: Channel) => { - console.info(info) - const client = accept(true) - - channel.pipe(client).pipe(channel) - }) - .catch((e) => { - console.error(e) - }) - }) - }) - - server.on('error', (err) => { - console.log('Server error:', err) - }) - - server.useAuth(socks.auth.None()) - - server.listen(settings.destinationPort, settings.destinationIP, () => { - console.log('Server listening on', settings.destinationPort, settings.destinationIP) - }) - } catch (e) { - console.log('Failed to create server', e) + const dynamicPortForwardObject: SSHConnection = { + eventEmitter: dynamicPortForwardInstance.eventEmitter, + 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) } + + return dynamicPortForwardObject } } @@ -118,4 +61,6 @@ if (process.contextIsolated) { window.electron = electronAPI // @ts-ignore (define in dts) window.api = api + // @ts-ignore (define in dts) + window.ssh = ssh } diff --git a/yarn.lock b/yarn.lock index cc77f67..a985be8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -645,13 +645,20 @@ dependencies: undici-types "~5.26.4" -"@types/node@^18.11.18", "@types/node@^18.17.5": +"@types/node@^18.11.18": version "18.19.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.3.tgz#e4723c4cb385641d61b983f6fe0b716abd5f8fc0" integrity sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg== dependencies: undici-types "~5.26.4" +"@types/node@^22.7.4": + version "22.7.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.4.tgz#e35d6f48dca3255ce44256ddc05dee1c23353fcc" + integrity sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg== + dependencies: + undici-types "~6.19.2" + "@types/plist@^3.0.1": version "3.0.5" resolved "https://registry.yarnpkg.com/@types/plist/-/plist-3.0.5.tgz#9a0c49c0f9886c8c8696a7904dd703f6284036e0" @@ -2867,13 +2874,6 @@ make-dir@^2.1.0: pify "^4.0.1" semver "^5.6.0" -make-dir@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - matcher@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca" @@ -3052,18 +3052,6 @@ node-releases@^2.0.14: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== -node-ssh@^13.2.0: - version "13.2.0" - resolved "https://registry.yarnpkg.com/node-ssh/-/node-ssh-13.2.0.tgz#06823c4817a5d31249c125a47d821d473f61491d" - integrity sha512-7vsKR2Bbs66th6IWCy/7SN4MSwlVt+G6QrHB631BjRUM8/LmvDugtYhi0uAmgvHS/+PVurfNBOmELf30rm0MZg== - dependencies: - is-stream "^2.0.0" - make-dir "^3.1.0" - sb-promise-queue "^2.1.0" - sb-scandir "^3.1.0" - shell-escape "^0.2.0" - ssh2 "^1.14.0" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -3461,18 +3449,6 @@ sax@^1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0" integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA== -sb-promise-queue@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/sb-promise-queue/-/sb-promise-queue-2.1.0.tgz#7e44bebef643f75d809a3db7f605b815d877a04d" - integrity sha512-zwq4YuP1FQFkGx2Q7GIkZYZ6PqWpV+bg0nIO1sJhWOyGyhqbj0MsTvK6lCFo5TQwX5pZr6SCQ75e8PCDCuNvkg== - -sb-scandir@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/sb-scandir/-/sb-scandir-3.1.0.tgz#31c346abb5184b73c5a25b286858f4299aa8756c" - integrity sha512-70BVm2xz9jn94zSQdpvYrEG101/UV9TVGcfWr9T5iob3QhCK4lYXeculfBqPGFv3XTeKgx4dpWyYIDeZUqo4kg== - dependencies: - sb-promise-queue "^2.1.0" - semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" @@ -3483,7 +3459,7 @@ semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^6.0.0, semver@^6.2.0, semver@^6.3.1: +semver@^6.2.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -3514,11 +3490,6 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-escape@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/shell-escape/-/shell-escape-0.2.0.tgz#68fd025eb0490b4f567a027f0bf22480b5f84133" - integrity sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw== - signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -3590,7 +3561,7 @@ sprintf@0.1.x: resolved "https://registry.yarnpkg.com/sprintf/-/sprintf-0.1.5.tgz#8f83e39a9317c1a502cb7db8050e51c679f6edcf" integrity sha512-4X5KsuXFQ7f+d7Y+bi4qSb6eI+YoifDTGr0MQJXRoYO7BO7evfRCjds6kk3z7l5CiJYxgDN1x5Er4WiyCt+zTQ== -ssh2@^1.14.0, ssh2@^1.16.0: +ssh2@^1.16.0: version "1.16.0" resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.16.0.tgz#79221d40cbf4d03d07fe881149de0a9de928c9f0" integrity sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg== @@ -3850,6 +3821,11 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"