diff --git a/backend/server/package-lock.json b/backend/server/package-lock.json index 8c7b2a0..d167660 100644 --- a/backend/server/package-lock.json +++ b/backend/server/package-lock.json @@ -16,12 +16,14 @@ "express": "^4.19.2", "rate-limiter-flexible": "^5.0.3", "socket.io": "^4.7.5", + "ssh2": "^1.15.0", "zod": "^3.22.4" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.12.7", + "@types/ssh2": "^1.15.0", "nodemon": "^3.1.0", "ts-node": "^10.9.2", "typescript": "^5.4.5" @@ -213,6 +215,24 @@ "@types/send": "*" } }, + "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.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.41.tgz", + "integrity": "sha512-LX84pRJ+evD2e2nrgYCHObGWkiQJ1mL+meAgbvnwk/US6vmMY7S2ygBTGV2Jw91s9vUsLSXeDEkUHZIJGLrhsg==", + "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", @@ -298,6 +318,14 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "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/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -312,6 +340,14 @@ "node": "^4.5.0 || >= 5.9" } }, + "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.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -382,6 +418,15 @@ "node": ">=6.14.2" } }, + "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/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -593,6 +638,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1247,6 +1306,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/nan": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", + "optional": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1748,6 +1813,23 @@ "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==" }, + "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/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -1880,6 +1962,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/backend/server/package.json b/backend/server/package.json index 3b89d67..dc0f09c 100644 --- a/backend/server/package.json +++ b/backend/server/package.json @@ -18,12 +18,14 @@ "express": "^4.19.2", "rate-limiter-flexible": "^5.0.3", "socket.io": "^4.7.5", + "ssh2": "^1.15.0", "zod": "^3.22.4" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.12.7", + "@types/ssh2": "^1.15.0", "nodemon": "^3.1.0", "ts-node": "^10.9.2", "typescript": "^5.4.5" diff --git a/backend/server/src/DokkuClient.ts b/backend/server/src/DokkuClient.ts new file mode 100644 index 0000000..fd0adcd --- /dev/null +++ b/backend/server/src/DokkuClient.ts @@ -0,0 +1,37 @@ +import { SSHSocketClient, SSHConfig } from "./SSHSocketClient" + +export interface DokkuResponse { + ok: boolean; + output: string; +} + +export class DokkuClient extends SSHSocketClient { + + constructor(config: SSHConfig) { + super( + config, + "/var/run/dokku-daemon/dokku-daemon.sock" + ) + } + + async sendCommand(command: string): Promise { + try { + const response = await this.sendData(command); + + if (typeof response !== "string") { + throw new Error("Received data is not a string"); + } + + return JSON.parse(response); + } catch (error: any) { + throw new Error(`Failed to send command: ${error.message}`); + } + } + + async listApps(): Promise { + const response = await this.sendCommand("apps:list"); + return response.output.split("\n").slice(1); // Split by newline and ignore the first line (header) + } +} + +export { SSHConfig }; \ No newline at end of file diff --git a/backend/server/src/SSHSocketClient.ts b/backend/server/src/SSHSocketClient.ts new file mode 100644 index 0000000..e0dc043 --- /dev/null +++ b/backend/server/src/SSHSocketClient.ts @@ -0,0 +1,90 @@ +import { Client } from "ssh2"; + +export interface SSHConfig { + host: string; + port?: number; + username: string; + privateKey: Buffer; +} + +export class SSHSocketClient { + private conn: Client; + private config: SSHConfig; + private socketPath: string; + private isConnected: boolean = false; + + constructor(config: SSHConfig, socketPath: string) { + this.conn = new Client(); + this.config = { ...config, port: 22}; + this.socketPath = socketPath; + + this.setupTerminationHandlers(); + } + + private setupTerminationHandlers() { + process.on("SIGINT", this.closeConnection.bind(this)); + process.on("SIGTERM", this.closeConnection.bind(this)); + } + + private closeConnection() { + console.log("Closing SSH connection..."); + this.conn.end(); + this.isConnected = false; + process.exit(0); + } + + connect(): Promise { + return new Promise((resolve, reject) => { + this.conn + .on("ready", () => { + console.log("SSH connection established"); + this.isConnected = true; + resolve(); + }) + .on("error", (err) => { + console.error("SSH connection error:", err); + this.isConnected = false; + reject(err); + }) + .on("close", () => { + console.log("SSH connection closed"); + this.isConnected = false; + }) + .connect(this.config); + }); + } + + sendData(data: string): Promise { + return new Promise((resolve, reject) => { + if (!this.isConnected) { + reject(new Error("SSH connection is not established")); + return; + } + + this.conn.exec( + `echo "${data}" | nc -U ${this.socketPath}`, + (err, stream) => { + if (err) { + reject(err); + return; + } + + stream + .on("close", (code: number, signal: string) => { + reject( + new Error( + `Stream closed with code ${code} and signal ${signal}` + ) + ); + }) + .on("data", (data: Buffer) => { + resolve(data.toString()); + }) + .stderr.on("data", (data: Buffer) => { + reject(new Error(data.toString())); + }); + } + ); + }); + } + } \ No newline at end of file diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index 6c74f26..187db67 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -5,7 +5,8 @@ import express, { Express } from "express"; import dotenv from "dotenv"; import { createServer } from "http"; import { Server } from "socket.io"; -import net from "net"; +import { DokkuClient, SSHConfig } from "./DokkuClient"; +import fs from "fs"; import { z } from "zod"; import { User } from "./types"; @@ -113,20 +114,17 @@ io.use(async (socket, next) => { const lockManager = new LockManager(); -const client = net.createConnection( - { path: "/var/run/remote-dokku.sock" }, - () => { - console.log("Connected to Dokku server"); - } -); +if (!process.env.DOKKU_HOST) throw new Error('Environment variable DOKKU_HOST is not defined'); +if (!process.env.DOKKU_USERNAME) throw new Error('Environment variable DOKKU_USERNAME is not defined'); +if (!process.env.DOKKU_KEY) throw new Error('Environment variable DOKKU_KEY is not defined'); -client.on("end", () => { - console.log("Disconnected from Dokku server"); +const client = new DokkuClient({ + host: process.env.DOKKU_HOST, + username: process.env.DOKKU_USERNAME, + privateKey: fs.readFileSync(process.env.DOKKU_KEY), }); -client.on("error", (err) => { - console.error(`Dokku Client error: ${err}`); -}); +client.connect(); io.on("connection", async (socket) => { try { @@ -270,11 +268,6 @@ io.on("connection", async (socket) => { } ); - interface DokkuResponse { - ok: boolean; - output: string; - } - interface CallbackResponse { success: boolean; apps?: string[]; @@ -285,20 +278,17 @@ io.on("connection", async (socket) => { "list", async (callback: (response: CallbackResponse) => void) => { console.log("Retrieving apps list..."); - client.on("data", (data) => { - const response = data.toString(); - const parsedData: DokkuResponse = JSON.parse(response); - if (parsedData.ok) { - const appsList = parsedData.output.split("\n").slice(1); // Split by newline and ignore the first line (header) - callback({ success: true, apps: appsList }); - } else { - callback({ - success: false, - message: "Failed to retrieve apps list", - }); - } - }); - client.write("apps:list\n"); + try { + callback({ + success: true, + apps: await client.listApps() + }); + } catch (error) { + callback({ + success: false, + message: "Failed to retrieve apps list", + }); + } } );