diff --git a/package-lock.json b/package-lock.json index 9c816ea..8e41775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,13 @@ "dependencies": { "@prisma/client": "^5.13.0", "bcrypt": "^5.1.1", - "fastify": "^4.26.2" + "fastify": "^4.26.2", + "node-ssh": "^13.2.0" }, "devDependencies": { "@types/bcrypt": "^5.0.2", "@types/node": "^20.12.7", + "@types/ssh2": "^1.15.0", "nodemon": "^3.0.3", "prisma": "^5.13.0", "typescript": "^5.3.3" @@ -165,6 +167,24 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/ssh2": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==", + "dev": true, + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", + "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -284,6 +304,14 @@ "node": ">= 6" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -340,6 +368,14 @@ "node": ">= 10.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -393,6 +429,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -454,6 +499,20 @@ "node": ">= 0.6" } }, + "node_modules/cpu-features": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz", + "integrity": "sha512-AKjgn2rP2yJyfbepsmLfiYcmtNn/2eUvocUyM/09yB0YDiz39HteK/5/T4Onf0pmdYDMgkBoGvRLvEguzyL7wQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.17.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -858,6 +917,17 @@ "node": ">=0.12.0" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/json-schema-ref-resolver": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", @@ -972,6 +1042,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/nan": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", + "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==", + "optional": true + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -996,6 +1072,22 @@ } } }, + "node_modules/node-ssh": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-13.2.0.tgz", + "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" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/nodemon": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.3.tgz", @@ -1312,6 +1404,30 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sb-promise-queue": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.0.tgz", + "integrity": "sha512-zwq4YuP1FQFkGx2Q7GIkZYZ6PqWpV+bg0nIO1sJhWOyGyhqbj0MsTvK6lCFo5TQwX5pZr6SCQ75e8PCDCuNvkg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/sb-scandir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.0.tgz", + "integrity": "sha512-70BVm2xz9jn94zSQdpvYrEG101/UV9TVGcfWr9T5iob3QhCK4lYXeculfBqPGFv3XTeKgx4dpWyYIDeZUqo4kg==", + "dependencies": { + "sb-promise-queue": "^2.1.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -1341,6 +1457,11 @@ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" }, + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw==" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1374,6 +1495,23 @@ "node": ">= 10.x" } }, + "node_modules/ssh2": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.9", + "nan": "^2.18.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1479,6 +1617,11 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, "node_modules/typescript": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", diff --git a/package.json b/package.json index df313dd..f821ae1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@types/bcrypt": "^5.0.2", "@types/node": "^20.12.7", + "@types/ssh2": "^1.15.0", "nodemon": "^3.0.3", "prisma": "^5.13.0", "typescript": "^5.3.3" @@ -23,6 +24,7 @@ "dependencies": { "@prisma/client": "^5.13.0", "bcrypt": "^5.1.1", - "fastify": "^4.26.2" + "fastify": "^4.26.2", + "node-ssh": "^13.2.0" } } diff --git a/src/backendimpl/base.ts b/src/backendimpl/base.ts index 99817d3..701263b 100644 --- a/src/backendimpl/base.ts +++ b/src/backendimpl/base.ts @@ -3,35 +3,57 @@ export type ParameterReturnedValue = { message?: string } -export type ConnectedDevice = { +export type ForwardRule = { sourceIP: string, sourcePort: number, - destPort: number, - - protocol: "tcp" | "udp" + destPort: number }; -export interface BackendInterface { - new(): { - addConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): void; - removeConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): void; - - start(): Promise, - stop(): Promise, +export type ConnectedClient = { + ip: string, + port: number, + + connectionDetails: ForwardRule +}; - getAllConnections(): { - sourceIP: string, - sourcePort: number, - destPort: number, - protocol: "tcp" | "udp" - }[]; +export class BackendBaseClass { + state: "stopped" | "stopping" | "started" | "starting"; - state: "stopped" | "running" | "starting"; + clients?: ConnectedClient[]; // Not required to be implemented, but more consistency + logs: string[]; - probeConnectedClients: ConnectedDevice[], - logs: string[] - }, + constructor(parameters: string) { + this.logs = []; + this.clients = []; + + this.state = "stopped"; + } - checkParametersConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): ParameterReturnedValue; - checkParametersBackendInstance(data: string): ParameterReturnedValue; + addConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): void {}; + removeConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): void {}; + + async start(): Promise { + return true; + } + + async stop(): Promise { + return true; + }; + + getAllConnections(): ConnectedClient[] { + if (this.clients == null) return []; + return this.clients; + }; + + static checkParametersConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): ParameterReturnedValue { + return { + success: true + } + }; + + static checkParametersBackendInstance(data: string): ParameterReturnedValue { + return { + success: true + } + }; } \ No newline at end of file diff --git a/src/backendimpl/index.ts b/src/backendimpl/index.ts new file mode 100644 index 0000000..50b2bc6 --- /dev/null +++ b/src/backendimpl/index.ts @@ -0,0 +1,6 @@ +import type { BackendBaseClass } from "./base.js"; +import { SSHBackendProvider } from "./ssh.js"; + +export const backendProviders: Record = { + "ssh": SSHBackendProvider +}; \ No newline at end of file diff --git a/src/backendimpl/ssh.ts b/src/backendimpl/ssh.ts new file mode 100644 index 0000000..71069b1 --- /dev/null +++ b/src/backendimpl/ssh.ts @@ -0,0 +1,211 @@ +import { NodeSSH } from "node-ssh"; +import { Socket } from "node:net"; + +import type { BackendBaseClass, ForwardRule, ConnectedClient, ParameterReturnedValue } from "./base.js"; + +// Fight me (for better naming) +type BackendParsedProviderString = { + ip: string, + port: number, + + username: string, + privateKey: string +} + +function parseBackendProviderString(data: string): BackendParsedProviderString { + try { + JSON.parse(data); + } catch (e) { + throw new Error("Payload body is not JSON") + } + + const jsonData = JSON.parse(data); + + if (typeof jsonData.ip != "string") throw new Error("IP field is not a string"); + if (typeof jsonData.port != "number") throw new Error("Port is not a number"); + + if (typeof jsonData.username != "string") throw new Error("Username is not a string"); + if (typeof jsonData.privateKey != "string") throw new Error("Private key is not a string"); + + return { + ip: jsonData.ip, + port: jsonData.port, + + username: jsonData.username, + privateKey: jsonData.privateKey + } +} + +export class SSHBackendProvider implements BackendBaseClass { + state: "stopped" | "stopping" | "started" | "starting"; + + clients: ConnectedClient[]; + proxies: ForwardRule[]; + logs: string[]; + + sshInstance: NodeSSH; + options: BackendParsedProviderString; + + constructor(parameters: string) { + this.logs = []; + this.proxies = []; + this.clients = []; + + this.options = parseBackendProviderString(parameters); + + this.state = "stopped"; + } + + async start(): Promise { + this.state = "starting"; + this.logs.push("Starting SSHBackendProvider..."); + + if (this.sshInstance) { + this.sshInstance.dispose(); + } + + this.sshInstance = new NodeSSH(); + + try { + await this.sshInstance.connect({ + host: this.options.ip, + port: this.options.port, + + username: this.options.username, + privateKey: this.options.privateKey + }); + } catch (e) { + this.logs.push(`Failed to start SSHBackendProvider! Error: '${e}'`); + this.state = "stopped"; + + // @ts-ignore + this.sshInstance = null; + + return false; + }; + + this.state = "started"; + this.logs.push("Successfully started SSHBackendProvider."); + + return true; + } + + async stop(): Promise { + this.state = "stopping"; + this.logs.push("Stopping SSHBackendProvider..."); + + this.proxies.splice(0, this.proxies.length); + + this.sshInstance.dispose(); + + // @ts-ignore + this.sshInstance = null; + + this.logs.push("Successfully stopped SSHBackendProvider."); + this.state = "stopped"; + + return true; + }; + + addConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): void { + const connectionCheck = SSHBackendProvider.checkParametersConnection(sourceIP, sourcePort, destPort, protocol); + if (!connectionCheck.success) throw new Error(connectionCheck.message); + + const foundProxyEntry = this.proxies.find((i) => i.sourceIP == sourceIP && i.sourcePort == sourcePort && i.destPort == destPort); + if (foundProxyEntry) return; + + console.log("connection added"); + + (async() => { + await this.sshInstance.forwardIn("0.0.0.0", destPort, (info, accept, reject) => { + const foundProxyEntry = this.proxies.find((i) => i.sourceIP == sourceIP && i.sourcePort == sourcePort && i.destPort == destPort); + if (!foundProxyEntry) return reject(); + + const client: ConnectedClient = { + ip: info.srcIP, + port: info.srcPort, + + connectionDetails: foundProxyEntry + }; + + this.clients.push(client); + + const srcConn = new Socket(); + + srcConn.connect({ + host: sourceIP, + port: sourcePort + }); + + // Why is this so confusing + const destConn = accept(); + + destConn.on("data", (chunk: Uint8Array) => { + srcConn.write(chunk); + }); + + destConn.on("exit", () => { + this.clients.splice(this.clients.indexOf(client), 1); + srcConn.end(); + }); + + srcConn.on("data", (data) => { + destConn.write(data); + }); + + srcConn.on("end", () => { + this.clients.splice(this.clients.indexOf(client), 1); + destConn.close(); + }); + }); + })(); + + this.proxies.push({ + sourceIP, + sourcePort, + destPort + }); + }; + + removeConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): void { + const connectionCheck = SSHBackendProvider.checkParametersConnection(sourceIP, sourcePort, destPort, protocol); + if (!connectionCheck.success) throw new Error(connectionCheck.message); + + const foundProxyEntry = this.proxies.find((i) => i.sourceIP == sourceIP && i.sourcePort == sourcePort && i.destPort == destPort); + if (!foundProxyEntry) return; + + const proxyIndex = this.proxies.indexOf(foundProxyEntry); + this.proxies.splice(proxyIndex, 1); + }; + + getAllConnections(): ConnectedClient[] { + return this.clients; + }; + + static checkParametersConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): ParameterReturnedValue { + if (protocol == "udp") return { + success: false, + message: "SSH does not support UDP tunneling! Please use something like PortCopier instead (if it gets done)" + }; + + return { + success: true + } + }; + + static checkParametersBackendInstance(data: string): ParameterReturnedValue { + try { + parseBackendProviderString(data); + // @ts-ignore + } catch (e: Error) { + return { + success: false, + message: e.toString() + } + } + + return { + success: true + } + }; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 1e99295..58d6269 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,9 @@ import { PrismaClient } from '@prisma/client'; import Fastify from "fastify"; import type { ServerOptions, SessionToken, RouteOptions } from "./libs/types.js"; -import type { BackendInterface } from "./backendimpl/base.js"; +import type { BackendBaseClass } from "./backendimpl/base.js"; + +import { backendProviders } from "./backendimpl/index.js"; import { route as getPermissions } from "./routes/getPermissions.js"; @@ -20,6 +22,7 @@ import { route as userCreate } from "./routes/user/create.js"; import { route as userRemove } from "./routes/user/remove.js"; import { route as userLookup } from "./routes/user/lookup.js"; import { route as userLogin } from "./routes/user/login.js"; +import { connect } from "node:http2"; const prisma = new PrismaClient(); @@ -40,7 +43,7 @@ const serverOptions: ServerOptions = { }; const sessionTokens: Record = {}; -const backends: Record = {}; +const backends: Record = {}; const fastify = Fastify({ logger: true @@ -55,6 +58,55 @@ const routeOptions: RouteOptions = { backends: backends }; +console.log("Initializing forwarding rules..."); + +const createdBackends = await prisma.desinationProvider.findMany(); + +for (const backend of createdBackends) { + console.log(`Running init steps for ID '${backend.id}' (${backend.name})`); + + const ourProvider = backendProviders[backend.backend]; + + if (!ourProvider) { + console.log(" - Error: Invalid backend recieved!"); + continue; + } + + console.log(" - Initializing backend..."); + + backends[backend.id] = new ourProvider(backend.connectionDetails); + const ourBackend = backends[backend.id]; + + if (!await ourBackend.start()) { + console.log(" - Error initializing backend!"); + console.log(" - " + ourBackend.logs.join("\n - ")); + + continue; + } + + console.log(" - Initializing clients..."); + + const clients = await prisma.forwardRule.findMany({ + where: { + destProviderID: backend.id, + enabled: true + } + }); + + for (const client of clients) { + if (client.protocol != "tcp" && client.protocol != "udp") { + console.error(` - Error: Client with ID of '${client.id}' has an invalid protocol! (must be either TCP or UDP)`); + continue; + } + + ourBackend.addConnection(client.sourceIP, client.sourcePort, client.destPort, client.protocol); + } + + console.log("Init successful."); +} + +console.log("Done."); + getPermissions(routeOptions); backendCreate(routeOptions); diff --git a/src/libs/types.ts b/src/libs/types.ts index 22fdc45..23c3c1f 100644 --- a/src/libs/types.ts +++ b/src/libs/types.ts @@ -1,7 +1,7 @@ import type { PrismaClient } from "@prisma/client"; import type { FastifyInstance } from "fastify"; -import type { BackendInterface } from "../backendimpl/base.js"; +import type { BackendBaseClass } from "../backendimpl/base.js"; export type ServerOptions = { isSignupEnabled: boolean; @@ -24,5 +24,5 @@ export type RouteOptions = { tokens: Record, options: ServerOptions, - backends: Record + backends: Record }; \ No newline at end of file