From de2de0573be360d0a8364de03608abe79972cf5f Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Tue, 26 Dec 2023 04:12:44 +0800 Subject: [PATCH] Multiple Dockge instances (#200) --- README.md | 19 +- backend/agent-manager.ts | 291 ++++++++++ backend/agent-socket-handler.ts | 7 + .../docker-socket-handler.ts | 112 ++-- .../terminal-socket-handler.ts | 108 ++-- backend/database.ts | 2 +- backend/dockge-server.ts | 143 +++-- backend/log.ts | 2 +- .../migrations/2023-12-20-2117-agent-table.ts | 16 + backend/models/agent.ts | 31 ++ backend/settings.ts | 2 +- .../agent-proxy-socket-handler.ts | 47 ++ .../socket-handlers/main-socket-handler.ts | 32 +- .../manage-agent-socket-handler.ts | 70 +++ backend/stack.ts | 52 +- backend/terminal.ts | 38 +- backend/util-server.ts | 16 +- common/agent-socket.ts | 15 + {backend => common}/util-common.ts | 24 +- extra/reset-password.ts | 3 +- frontend/src/components/Container.vue | 41 +- frontend/src/components/NetworkInput.vue | 6 +- frontend/src/components/StackList.vue | 7 +- frontend/src/components/StackListItem.vue | 51 +- frontend/src/components/Terminal.vue | 26 +- frontend/src/components/Uptime.vue | 2 +- frontend/src/lang/en.json | 14 +- frontend/src/layouts/Layout.vue | 3 +- frontend/src/main.ts | 2 +- frontend/src/mixins/socket.ts | 104 +++- frontend/src/pages/Compose.vue | 100 +++- frontend/src/pages/Console.vue | 9 +- frontend/src/pages/ContainerTerminal.vue | 20 +- frontend/src/pages/DashboardHome.vue | 159 +++++- frontend/src/router.ts | 17 +- package.json | 8 +- pnpm-lock.yaml | 520 +++++++++--------- tsconfig.json | 3 +- 38 files changed, 1525 insertions(+), 597 deletions(-) create mode 100644 backend/agent-manager.ts create mode 100644 backend/agent-socket-handler.ts rename backend/{socket-handlers => agent-socket-handlers}/docker-socket-handler.ts (69%) rename backend/{socket-handlers => agent-socket-handlers}/terminal-socket-handler.ts (61%) create mode 100644 backend/migrations/2023-12-20-2117-agent-table.ts create mode 100644 backend/models/agent.ts create mode 100644 backend/socket-handlers/agent-proxy-socket-handler.ts create mode 100644 backend/socket-handlers/manage-agent-socket-handler.ts create mode 100644 common/agent-socket.ts rename {backend => common}/util-common.ts (93%) diff --git a/README.md b/README.md index 64399af..04a5f16 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,17 @@ View Video: https://youtu.be/AWAlOQeNpgU?t=48 ## ⭐ Features -- Manage `compose.yaml` +- 🧑‍💼 Manage your `compose.yaml` files - Create/Edit/Start/Stop/Restart/Delete - Update Docker Images -- Interactive Editor for `compose.yaml` -- Interactive Web Terminal -- Reactive - - Everything is just responsive. Progress (Pull/Up/Down) and terminal output are in real-time -- Easy-to-use & fancy UI - - If you love Uptime Kuma's UI/UX, you will love this one too -- Convert `docker run ...` commands into `compose.yaml` -- File based structure - - Dockge won't kidnap your compose files, they are stored on your drive as usual. You can interact with them using normal `docker compose` commands +- ⌨️ Interactive Editor for `compose.yaml` +- 🦦 Interactive Web Terminal +- 🕷️ (1.4.0 🆕) Multiple agents support - You can manage multiple stacks from different Docker hosts in one single interface +- 🏪 Convert `docker run ...` commands into `compose.yaml` +- 📙 File based structure - Dockge won't kidnap your compose files, they are stored on your drive as usual. You can interact with them using normal `docker compose` commands - +- 🚄 Reactive - Everything is just responsive. Progress (Pull/Up/Down) and terminal output are in real-time +- 🐣 Easy-to-use & fancy UI - If you love Uptime Kuma's UI/UX, you will love this one too ![](https://github.com/louislam/dockge/assets/1336778/89fc1023-b069-42c0-a01c-918c495f1a6a) diff --git a/backend/agent-manager.ts b/backend/agent-manager.ts new file mode 100644 index 0000000..895232a --- /dev/null +++ b/backend/agent-manager.ts @@ -0,0 +1,291 @@ +import { DockgeSocket } from "./util-server"; +import { io, Socket as SocketClient } from "socket.io-client"; +import { log } from "./log"; +import { Agent } from "./models/agent"; +import { isDev, LooseObject, sleep } from "../common/util-common"; +import semver from "semver"; +import { R } from "redbean-node"; +import dayjs, { Dayjs } from "dayjs"; + +/** + * Dockge Instance Manager + * One AgentManager per Socket connection + */ +export class AgentManager { + + protected socket : DockgeSocket; + protected agentSocketList : Record = {}; + protected agentLoggedInList : Record = {}; + protected _firstConnectTime : Dayjs = dayjs(); + + constructor(socket: DockgeSocket) { + this.socket = socket; + } + + get firstConnectTime() : Dayjs { + return this._firstConnectTime; + } + + test(url : string, username : string, password : string) : Promise { + return new Promise((resolve, reject) => { + let obj = new URL(url); + let endpoint = obj.host; + + if (!endpoint) { + reject(new Error("Invalid Dockge URL")); + } + + if (this.agentSocketList[endpoint]) { + reject(new Error("The Dockge URL already exists")); + } + + let client = io(url, { + reconnection: false, + extraHeaders: { + endpoint, + } + }); + + client.on("connect", () => { + client.emit("login", { + username: username, + password: password, + }, (res : LooseObject) => { + if (res.ok) { + resolve(); + } else { + reject(new Error(res.msg)); + } + client.disconnect(); + }); + }); + + client.on("connect_error", (err) => { + if (err.message === "xhr poll error") { + reject(new Error("Unable to connect to the Dockge instance")); + } else { + reject(err); + } + client.disconnect(); + }); + }); + } + + /** + * + * @param url + * @param username + * @param password + */ + async add(url : string, username : string, password : string) : Promise { + let bean = R.dispense("agent") as Agent; + bean.url = url; + bean.username = username; + bean.password = password; + await R.store(bean); + return bean; + } + + /** + * + * @param url + */ + async remove(url : string) { + let bean = await R.findOne("agent", " url = ? ", [ + url, + ]); + + if (bean) { + await R.trash(bean); + let endpoint = bean.endpoint; + delete this.agentSocketList[endpoint]; + } else { + throw new Error("Agent not found"); + } + } + + connect(url : string, username : string, password : string) { + let obj = new URL(url); + let endpoint = obj.host; + + this.socket.emit("agentStatus", { + endpoint: endpoint, + status: "connecting", + }); + + if (!endpoint) { + log.error("agent-manager", "Invalid endpoint: " + endpoint + " URL: " + url); + return; + } + + if (this.agentSocketList[endpoint]) { + log.debug("agent-manager", "Already connected to the socket server: " + endpoint); + return; + } + + log.info("agent-manager", "Connecting to the socket server: " + endpoint); + let client = io(url, { + extraHeaders: { + endpoint, + } + }); + + client.on("connect", () => { + log.info("agent-manager", "Connected to the socket server: " + endpoint); + + client.emit("login", { + username: username, + password: password, + }, (res : LooseObject) => { + if (res.ok) { + log.info("agent-manager", "Logged in to the socket server: " + endpoint); + this.agentLoggedInList[endpoint] = true; + this.socket.emit("agentStatus", { + endpoint: endpoint, + status: "online", + }); + } else { + log.error("agent-manager", "Failed to login to the socket server: " + endpoint); + this.agentLoggedInList[endpoint] = false; + this.socket.emit("agentStatus", { + endpoint: endpoint, + status: "offline", + }); + } + }); + }); + + client.on("connect_error", (err) => { + log.error("agent-manager", "Error from the socket server: " + endpoint); + this.socket.emit("agentStatus", { + endpoint: endpoint, + status: "offline", + }); + }); + + client.on("disconnect", () => { + log.info("agent-manager", "Disconnected from the socket server: " + endpoint); + this.socket.emit("agentStatus", { + endpoint: endpoint, + status: "offline", + }); + }); + + client.on("agent", (...args : unknown[]) => { + this.socket.emit("agent", ...args); + }); + + client.on("info", (res) => { + log.debug("agent-manager", res); + + // Disconnect if the version is lower than 1.4.0 + if (!isDev && semver.satisfies(res.version, "< 1.4.0")) { + this.socket.emit("agentStatus", { + endpoint: endpoint, + status: "offline", + msg: `${endpoint}: Unsupported version: ` + res.version, + }); + client.disconnect(); + } + }); + + this.agentSocketList[endpoint] = client; + } + + disconnect(endpoint : string) { + let client = this.agentSocketList[endpoint]; + client?.disconnect(); + } + + async connectAll() { + this._firstConnectTime = dayjs(); + + if (this.socket.endpoint) { + log.info("agent-manager", "This connection is connected as an agent, skip connectAll()"); + return; + } + + let list : Record = await Agent.getAgentList(); + + if (Object.keys(list).length !== 0) { + log.info("agent-manager", "Connecting to all instance socket server(s)..."); + } + + for (let endpoint in list) { + let agent = list[endpoint]; + this.connect(agent.url, agent.username, agent.password); + } + } + + disconnectAll() { + for (let endpoint in this.agentSocketList) { + this.disconnect(endpoint); + } + } + + async emitToEndpoint(endpoint: string, eventName: string, ...args : unknown[]) { + log.debug("agent-manager", "Emitting event to endpoint: " + endpoint); + let client = this.agentSocketList[endpoint]; + + if (!client) { + log.error("agent-manager", "Socket client not found for endpoint: " + endpoint); + throw new Error("Socket client not found for endpoint: " + endpoint); + } + + if (!client.connected || !this.agentLoggedInList[endpoint]) { + // Maybe the request is too quick, the socket is not connected yet, check firstConnectTime + // If it is within 10 seconds, we should apply retry logic here + let diff = dayjs().diff(this.firstConnectTime, "second"); + log.debug("agent-manager", endpoint + ": diff: " + diff); + let ok = false; + while (diff < 10) { + if (client.connected && this.agentLoggedInList[endpoint]) { + log.debug("agent-manager", `${endpoint}: Connected & Logged in`); + ok = true; + break; + } + log.debug("agent-manager", endpoint + ": not ready yet, retrying in 1 second..."); + await sleep(1000); + diff = dayjs().diff(this.firstConnectTime, "second"); + } + + if (!ok) { + log.error("agent-manager", `${endpoint}: Socket client not connected`); + throw new Error("Socket client not connected for endpoint: " + endpoint); + } + } + + client.emit("agent", endpoint, eventName, ...args); + } + + emitToAllEndpoints(eventName: string, ...args : unknown[]) { + log.debug("agent-manager", "Emitting event to all endpoints"); + for (let endpoint in this.agentSocketList) { + this.emitToEndpoint(endpoint, eventName, ...args).catch((e) => { + log.warn("agent-manager", e.message); + }); + } + } + + async sendAgentList() { + let list = await Agent.getAgentList(); + let result : Record = {}; + + // Myself + result[""] = { + url: "", + username: "", + endpoint: "", + }; + + for (let endpoint in list) { + let agent = list[endpoint]; + result[endpoint] = agent.toJSON(); + } + + this.socket.emit("agentList", { + ok: true, + agentList: result, + }); + } +} diff --git a/backend/agent-socket-handler.ts b/backend/agent-socket-handler.ts new file mode 100644 index 0000000..7886fe0 --- /dev/null +++ b/backend/agent-socket-handler.ts @@ -0,0 +1,7 @@ +import { DockgeServer } from "./dockge-server"; +import { AgentSocket } from "../common/agent-socket"; +import { DockgeSocket } from "./util-server"; + +export abstract class AgentSocketHandler { + abstract create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket): void; +} diff --git a/backend/socket-handlers/docker-socket-handler.ts b/backend/agent-socket-handlers/docker-socket-handler.ts similarity index 69% rename from backend/socket-handlers/docker-socket-handler.ts rename to backend/agent-socket-handlers/docker-socket-handler.ts index 036d171..93abe85 100644 --- a/backend/socket-handlers/docker-socket-handler.ts +++ b/backend/agent-socket-handlers/docker-socket-handler.ts @@ -1,45 +1,44 @@ -import { SocketHandler } from "../socket-handler.js"; +import { AgentSocketHandler } from "../agent-socket-handler"; import { DockgeServer } from "../dockge-server"; -import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server"; +import { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from "../util-server"; import { Stack } from "../stack"; +import { AgentSocket } from "../../common/agent-socket"; -// @ts-ignore -import composerize from "composerize"; +export class DockerSocketHandler extends AgentSocketHandler { + create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) { + // Do not call super.create() -export class DockerSocketHandler extends SocketHandler { - create(socket : DockgeSocket, server : DockgeServer) { - - socket.on("deployStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => { + agentSocket.on("deployStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => { try { checkLogin(socket); - const stack = await this.saveStack(socket, server, name, composeYAML, composeENV, isAdd); + const stack = await this.saveStack(server, name, composeYAML, composeENV, isAdd); await stack.deploy(socket); server.sendStackList(); - callback({ + callbackResult({ ok: true, msg: "Deployed", - }); + }, callback); stack.joinCombinedTerminal(socket); } catch (e) { callbackError(e, callback); } }); - socket.on("saveStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => { + agentSocket.on("saveStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => { try { checkLogin(socket); - this.saveStack(socket, server, name, composeYAML, composeENV, isAdd); - callback({ + await this.saveStack(server, name, composeYAML, composeENV, isAdd); + callbackResult({ ok: true, "msg": "Saved" - }); + }, callback); server.sendStackList(); } catch (e) { callbackError(e, callback); } }); - socket.on("deleteStack", async (name : unknown, callback) => { + agentSocket.on("deleteStack", async (name : unknown, callback) => { try { checkLogin(socket); if (typeof(name) !== "string") { @@ -55,17 +54,17 @@ export class DockerSocketHandler extends SocketHandler { } server.sendStackList(); - callback({ + callbackResult({ ok: true, msg: "Deleted" - }); + }, callback); } catch (e) { callbackError(e, callback); } }); - socket.on("getStack", async (stackName : unknown, callback) => { + agentSocket.on("getStack", async (stackName : unknown, callback) => { try { checkLogin(socket); @@ -79,31 +78,31 @@ export class DockerSocketHandler extends SocketHandler { stack.joinCombinedTerminal(socket); } - callback({ + callbackResult({ ok: true, - stack: stack.toJSON(), - }); + stack: await stack.toJSON(socket.endpoint), + }, callback); } catch (e) { callbackError(e, callback); } }); // requestStackList - socket.on("requestStackList", async (callback) => { + agentSocket.on("requestStackList", async (callback) => { try { checkLogin(socket); server.sendStackList(); - callback({ + callbackResult({ ok: true, msg: "Updated" - }); + }, callback); } catch (e) { callbackError(e, callback); } }); // startStack - socket.on("startStack", async (stackName : unknown, callback) => { + agentSocket.on("startStack", async (stackName : unknown, callback) => { try { checkLogin(socket); @@ -113,10 +112,10 @@ export class DockerSocketHandler extends SocketHandler { const stack = await Stack.getStack(server, stackName); await stack.start(socket); - callback({ + callbackResult({ ok: true, msg: "Started" - }); + }, callback); server.sendStackList(); stack.joinCombinedTerminal(socket); @@ -127,7 +126,7 @@ export class DockerSocketHandler extends SocketHandler { }); // stopStack - socket.on("stopStack", async (stackName : unknown, callback) => { + agentSocket.on("stopStack", async (stackName : unknown, callback) => { try { checkLogin(socket); @@ -137,10 +136,10 @@ export class DockerSocketHandler extends SocketHandler { const stack = await Stack.getStack(server, stackName); await stack.stop(socket); - callback({ + callbackResult({ ok: true, msg: "Stopped" - }); + }, callback); server.sendStackList(); } catch (e) { callbackError(e, callback); @@ -148,7 +147,7 @@ export class DockerSocketHandler extends SocketHandler { }); // restartStack - socket.on("restartStack", async (stackName : unknown, callback) => { + agentSocket.on("restartStack", async (stackName : unknown, callback) => { try { checkLogin(socket); @@ -158,10 +157,10 @@ export class DockerSocketHandler extends SocketHandler { const stack = await Stack.getStack(server, stackName); await stack.restart(socket); - callback({ + callbackResult({ ok: true, msg: "Restarted" - }); + }, callback); server.sendStackList(); } catch (e) { callbackError(e, callback); @@ -169,7 +168,7 @@ export class DockerSocketHandler extends SocketHandler { }); // updateStack - socket.on("updateStack", async (stackName : unknown, callback) => { + agentSocket.on("updateStack", async (stackName : unknown, callback) => { try { checkLogin(socket); @@ -179,10 +178,10 @@ export class DockerSocketHandler extends SocketHandler { const stack = await Stack.getStack(server, stackName); await stack.update(socket); - callback({ + callbackResult({ ok: true, msg: "Updated" - }); + }, callback); server.sendStackList(); } catch (e) { callbackError(e, callback); @@ -190,7 +189,7 @@ export class DockerSocketHandler extends SocketHandler { }); // down stack - socket.on("downStack", async (stackName : unknown, callback) => { + agentSocket.on("downStack", async (stackName : unknown, callback) => { try { checkLogin(socket); @@ -200,10 +199,10 @@ export class DockerSocketHandler extends SocketHandler { const stack = await Stack.getStack(server, stackName); await stack.down(socket); - callback({ + callbackResult({ ok: true, msg: "Downed" - }); + }, callback); server.sendStackList(); } catch (e) { callbackError(e, callback); @@ -211,7 +210,7 @@ export class DockerSocketHandler extends SocketHandler { }); // Services status - socket.on("serviceStatusList", async (stackName : unknown, callback) => { + agentSocket.on("serviceStatusList", async (stackName : unknown, callback) => { try { checkLogin(socket); @@ -221,50 +220,31 @@ export class DockerSocketHandler extends SocketHandler { const stack = await Stack.getStack(server, stackName, true); const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList()); - callback({ + callbackResult({ ok: true, serviceStatusList, - }); + }, callback); } catch (e) { callbackError(e, callback); } }); // getExternalNetworkList - socket.on("getDockerNetworkList", async (callback) => { + agentSocket.on("getDockerNetworkList", async (callback) => { try { checkLogin(socket); const dockerNetworkList = await server.getDockerNetworkList(); - callback({ + callbackResult({ ok: true, dockerNetworkList, - }); - } catch (e) { - callbackError(e, callback); - } - }); - - // composerize - socket.on("composerize", async (dockerRunCommand : unknown, callback) => { - try { - checkLogin(socket); - - if (typeof(dockerRunCommand) !== "string") { - throw new ValidationError("dockerRunCommand must be a string"); - } - - const composeTemplate = composerize(dockerRunCommand); - callback({ - ok: true, - composeTemplate, - }); + }, callback); } catch (e) { callbackError(e, callback); } }); } - async saveStack(socket : DockgeSocket, server : DockgeServer, name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown) : Promise { + async saveStack(server : DockgeServer, name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown) : Promise { // Check types if (typeof(name) !== "string") { throw new ValidationError("Name must be a string"); diff --git a/backend/socket-handlers/terminal-socket-handler.ts b/backend/agent-socket-handlers/terminal-socket-handler.ts similarity index 61% rename from backend/socket-handlers/terminal-socket-handler.ts rename to backend/agent-socket-handlers/terminal-socket-handler.ts index 9ae6656..6367962 100644 --- a/backend/socket-handlers/terminal-socket-handler.ts +++ b/backend/agent-socket-handlers/terminal-socket-handler.ts @@ -1,24 +1,15 @@ -import { SocketHandler } from "../socket-handler.js"; import { DockgeServer } from "../dockge-server"; -import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server"; +import { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from "../util-server"; import { log } from "../log"; -import yaml from "yaml"; -import path from "path"; -import fs from "fs"; -import { - allowedCommandList, - allowedRawKeys, - getComposeTerminalName, getContainerExecTerminalName, - isDev, - PROGRESS_TERMINAL_ROWS -} from "../util-common"; import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal"; import { Stack } from "../stack"; +import { AgentSocketHandler } from "../agent-socket-handler"; +import { AgentSocket } from "../../common/agent-socket"; -export class TerminalSocketHandler extends SocketHandler { - create(socket : DockgeSocket, server : DockgeServer) { +export class TerminalSocketHandler extends AgentSocketHandler { + create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) { - socket.on("terminalInput", async (terminalName : unknown, cmd : unknown, errorCallback) => { + agentSocket.on("terminalInput", async (terminalName : unknown, cmd : unknown, callback) => { try { checkLogin(socket); @@ -38,17 +29,12 @@ export class TerminalSocketHandler extends SocketHandler { throw new Error("Terminal not found or it is not a Interactive Terminal."); } } catch (e) { - if (e instanceof Error) { - errorCallback({ - ok: false, - msg: e.message, - }); - } + callbackError(e, callback); } }); // Main Terminal - socket.on("mainTerminal", async (terminalName : unknown, callback) => { + agentSocket.on("mainTerminal", async (terminalName : unknown, callback) => { try { checkLogin(socket); @@ -59,29 +45,29 @@ export class TerminalSocketHandler extends SocketHandler { throw new ValidationError("Terminal name must be a string."); } - log.debug("deployStack", "Terminal name: " + terminalName); + log.debug("mainTerminal", "Terminal name: " + terminalName); let terminal = Terminal.getTerminal(terminalName); if (!terminal) { terminal = new MainTerminal(server, terminalName); terminal.rows = 50; - log.debug("deployStack", "Terminal created"); + log.debug("mainTerminal", "Terminal created"); } terminal.join(socket); terminal.start(); - callback({ + callbackResult({ ok: true, - }); + }, callback); } catch (e) { callbackError(e, callback); } }); // Interactive Terminal for containers - socket.on("interactiveTerminal", async (stackName : unknown, serviceName : unknown, shell : unknown, callback) => { + agentSocket.on("interactiveTerminal", async (stackName : unknown, serviceName : unknown, shell : unknown, callback) => { try { checkLogin(socket); @@ -104,16 +90,16 @@ export class TerminalSocketHandler extends SocketHandler { const stack = await Stack.getStack(server, stackName); stack.joinContainerTerminal(socket, serviceName, shell); - callback({ + callbackResult({ ok: true, - }); + }, callback); } catch (e) { callbackError(e, callback); } }); // Join Output Terminal - socket.on("terminalJoin", async (terminalName : unknown, callback) => { + agentSocket.on("terminalJoin", async (terminalName : unknown, callback) => { if (typeof(callback) !== "function") { log.debug("console", "Callback is not a function."); return; @@ -141,7 +127,7 @@ export class TerminalSocketHandler extends SocketHandler { }); // Leave Combined Terminal - socket.on("leaveCombinedTerminal", async (stackName : unknown, callback) => { + agentSocket.on("leaveCombinedTerminal", async (stackName : unknown, callback) => { try { checkLogin(socket); @@ -154,52 +140,48 @@ export class TerminalSocketHandler extends SocketHandler { const stack = await Stack.getStack(server, stackName); await stack.leaveCombinedTerminal(socket); - callback({ + callbackResult({ ok: true, - }); + }, callback); } catch (e) { callbackError(e, callback); } }); // Resize Terminal - socket.on( - "terminalResize", - async (terminalName: unknown, rows: unknown, cols: unknown) => { - log.info("terminalResize", `Terminal: ${terminalName}`); - try { - checkLogin(socket); - if (typeof terminalName !== "string") { - throw new Error("Terminal name must be a string."); - } + agentSocket.on("terminalResize", async (terminalName: unknown, rows: unknown, cols: unknown) => { + log.info("terminalResize", `Terminal: ${terminalName}`); + try { + checkLogin(socket); + if (typeof terminalName !== "string") { + throw new Error("Terminal name must be a string."); + } - if (typeof rows !== "number") { - throw new Error("Command must be a number."); - } - if (typeof cols !== "number") { - throw new Error("Command must be a number."); - } + if (typeof rows !== "number") { + throw new Error("Command must be a number."); + } + if (typeof cols !== "number") { + throw new Error("Command must be a number."); + } - let terminal = Terminal.getTerminal(terminalName); + let terminal = Terminal.getTerminal(terminalName); - // log.info("terminal", terminal); - if (terminal instanceof Terminal) { - //log.debug("terminalInput", "Terminal found, writing to terminal."); - terminal.rows = rows; - terminal.cols = cols; - } else { - throw new Error(`${terminalName} Terminal not found.`); - } - } catch (e) { - log.debug( - "terminalResize", + // log.info("terminal", terminal); + if (terminal instanceof Terminal) { + //log.debug("terminalInput", "Terminal found, writing to terminal."); + terminal.rows = rows; + terminal.cols = cols; + } else { + throw new Error(`${terminalName} Terminal not found.`); + } + } catch (e) { + log.debug("terminalResize", // Added to prevent the lint error when adding the type // and ts type checker saying type is unknown. // @ts-ignore `Error on ${terminalName}: ${e.message}` - ); - } + ); } - ); + }); } } diff --git a/backend/database.ts b/backend/database.ts index a63bc18..0daa0db 100644 --- a/backend/database.ts +++ b/backend/database.ts @@ -9,7 +9,7 @@ import knex from "knex"; import Dialect from "knex/lib/dialects/sqlite3/index.js"; import sqlite from "@louislam/sqlite3"; -import { sleep } from "./util-common"; +import { sleep } from "../common/util-common"; interface DBConfig { type?: "sqlite" | "mysql"; diff --git a/backend/dockge-server.ts b/backend/dockge-server.ts index a55ecc3..8f734cc 100644 --- a/backend/dockge-server.ts +++ b/backend/dockge-server.ts @@ -1,3 +1,4 @@ +import "dotenv/config"; import { MainRouter } from "./routers/main-router"; import * as fs from "node:fs"; import { PackageJson } from "type-fest"; @@ -17,23 +18,26 @@ import { Settings } from "./settings"; import checkVersion from "./check-version"; import dayjs from "dayjs"; import { R } from "redbean-node"; -import { genSecret, isDev } from "./util-common"; +import { genSecret, isDev, LooseObject } from "../common/util-common"; import { generatePasswordHash } from "./password-hash"; import { Bean } from "redbean-node/dist/bean"; import { Arguments, Config, DockgeSocket } from "./util-server"; -import { DockerSocketHandler } from "./socket-handlers/docker-socket-handler"; +import { DockerSocketHandler } from "./agent-socket-handlers/docker-socket-handler"; import expressStaticGzip from "express-static-gzip"; import path from "path"; -import { TerminalSocketHandler } from "./socket-handlers/terminal-socket-handler"; +import { TerminalSocketHandler } from "./agent-socket-handlers/terminal-socket-handler"; import { Stack } from "./stack"; import { Cron } from "croner"; import gracefulShutdown from "http-graceful-shutdown"; import User from "./models/user"; import childProcessAsync from "promisify-child-process"; +import { AgentManager } from "./agent-manager"; +import { AgentProxySocketHandler } from "./socket-handlers/agent-proxy-socket-handler"; +import { AgentSocketHandler } from "./agent-socket-handler"; +import { AgentSocket } from "../common/agent-socket"; +import { ManageAgentSocketHandler } from "./socket-handlers/manage-agent-socket-handler"; import { Terminal } from "./terminal"; -import "dotenv/config"; - export class DockgeServer { app : Express; httpServer : http.Server; @@ -50,10 +54,19 @@ export class DockgeServer { ]; /** - * List of socket handlers + * List of socket handlers (no agent support) */ socketHandlerList : SocketHandler[] = [ new MainSocketHandler(), + new ManageAgentSocketHandler(), + ]; + + agentProxySocketHandler = new AgentProxySocketHandler(); + + /** + * List of socket handlers (support agent) + */ + agentSocketHandlerList : AgentSocketHandler[] = [ new DockerSocketHandler(), new TerminalSocketHandler(), ]; @@ -196,7 +209,7 @@ export class DockgeServer { cors, allowRequest: (req, callback) => { let isOriginValid = true; - const bypass = isDev; + const bypass = isDev || process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass"; if (!bypass) { let host = req.headers.host; @@ -230,20 +243,52 @@ export class DockgeServer { }); this.io.on("connection", async (socket: Socket) => { - log.info("server", "Socket connected!"); + let dockgeSocket = socket as DockgeSocket; + dockgeSocket.instanceManager = new AgentManager(dockgeSocket); + dockgeSocket.emitAgent = (event : string, ...args : unknown[]) => { + let obj = args[0]; + if (typeof(obj) === "object") { + let obj2 = obj as LooseObject; + obj2.endpoint = dockgeSocket.endpoint; + } + dockgeSocket.emit("agent", event, ...args); + }; - this.sendInfo(socket, true); + if (typeof(socket.request.headers.endpoint) === "string") { + dockgeSocket.endpoint = socket.request.headers.endpoint; + } else { + dockgeSocket.endpoint = ""; + } + + if (dockgeSocket.endpoint) { + log.info("server", "Socket connected (agent), as endpoint " + dockgeSocket.endpoint); + } else { + log.info("server", "Socket connected (direct)"); + } + + this.sendInfo(dockgeSocket, true); if (this.needSetup) { log.info("server", "Redirect to setup page"); - socket.emit("setup"); + dockgeSocket.emit("setup"); } - // Create socket handlers + // Create socket handlers (original, no agent support) for (const socketHandler of this.socketHandlerList) { - socketHandler.create(socket as DockgeSocket, this); + socketHandler.create(dockgeSocket, this); } + // Create Agent Socket + let agentSocket = new AgentSocket(); + + // Create agent socket handlers + for (const socketHandler of this.agentSocketHandlerList) { + socketHandler.create(dockgeSocket, this, agentSocket); + } + + // Create agent proxy socket handlers + this.agentProxySocketHandler.create2(dockgeSocket, this, agentSocket); + // *************************** // Better do anything after added all socket handlers here // *************************** @@ -251,12 +296,18 @@ export class DockgeServer { log.debug("auth", "check auto login"); if (await Settings.get("disableAuth")) { log.info("auth", "Disabled Auth: auto login to admin"); - this.afterLogin(socket as DockgeSocket, await R.findOne("user") as User); - socket.emit("autoLogin"); + this.afterLogin(dockgeSocket, await R.findOne("user") as User); + dockgeSocket.emit("autoLogin"); } else { log.debug("auth", "need auth"); } + // Socket disconnect + dockgeSocket.on("disconnect", () => { + log.info("server", "Socket disconnected!"); + dockgeSocket.instanceManager.disconnectAll(); + }); + }); this.io.on("disconnect", () => { @@ -281,6 +332,11 @@ export class DockgeServer { } catch (e) { log.error("server", e); } + + socket.instanceManager.sendAgentList(); + + // Also connect to other dockge instances + socket.instanceManager.connectAll(); } /** @@ -519,26 +575,34 @@ export class DockgeServer { return jwtSecretBean; } + /** + * Send stack list to all connected sockets + * @param useCache + */ async sendStackList(useCache = false) { - let roomList = this.io.sockets.adapter.rooms.keys(); - let map : Map | undefined; + let socketList = this.io.sockets.sockets.values(); + + let stackList; + + for (let socket of socketList) { + let dockgeSocket = socket as DockgeSocket; - for (let room of roomList) { // Check if the room is a number (user id) - if (Number(room)) { + if (dockgeSocket.userID) { - // Get the list only if there is a room - if (!map) { - map = new Map(); - let stackList = await Stack.getStackList(this, useCache); - - for (let [ stackName, stack ] of stackList) { - map.set(stackName, stack.toSimpleJSON()); - } + // Get the list only if there is a logged in user + if (!stackList) { + stackList = await Stack.getStackList(this, useCache); } - log.debug("server", "Send stack list to room " + room); - this.io.to(room).emit("stackList", { + let map : Map = new Map(); + + for (let [ stackName, stack ] of stackList) { + map.set(stackName, stack.toSimpleJSON(dockgeSocket.endpoint)); + } + + log.debug("server", "Send stack list to user: " + dockgeSocket.id + " (" + dockgeSocket.endpoint + ")"); + dockgeSocket.emitAgent("stackList", { ok: true, stackList: Object.fromEntries(map), }); @@ -546,25 +610,6 @@ export class DockgeServer { } } - async sendStackStatusList() { - let statusList = await Stack.getStatusList(); - - let roomList = this.io.sockets.adapter.rooms.keys(); - - for (let room of roomList) { - // Check if the room is a number (user id) - if (Number(room)) { - log.debug("server", "Send stack status list to room " + room); - this.io.to(room).emit("stackStatusList", { - ok: true, - stackStatusList: Object.fromEntries(statusList), - }); - } else { - log.debug("server", "Skip sending stack status list to room " + room); - } - } - } - async getDockerNetworkList() : Promise { let res = await childProcessAsync.spawn("docker", [ "network", "ls", "--format", "{{.Name}}" ], { encoding: "utf-8", @@ -618,10 +663,10 @@ export class DockgeServer { * @param {string} userID * @param {string?} currentSocketID */ - disconnectAllSocketClients(userID: number, currentSocketID? : string) { + disconnectAllSocketClients(userID: number | undefined, currentSocketID? : string) { for (const rawSocket of this.io.sockets.sockets.values()) { let socket = rawSocket as DockgeSocket; - if (socket.userID === userID && socket.id !== currentSocketID) { + if ((!userID || socket.userID === userID) && socket.id !== currentSocketID) { try { socket.emit("refresh"); socket.disconnect(); diff --git a/backend/log.ts b/backend/log.ts index 3cfac64..853e2a9 100644 --- a/backend/log.ts +++ b/backend/log.ts @@ -1,6 +1,6 @@ // Console colors // https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color -import { intHash, isDev } from "./util-common"; +import { intHash, isDev } from "../common/util-common"; import dayjs from "dayjs"; export const CONSOLE_STYLE_Reset = "\x1b[0m"; diff --git a/backend/migrations/2023-12-20-2117-agent-table.ts b/backend/migrations/2023-12-20-2117-agent-table.ts new file mode 100644 index 0000000..ae18d1d --- /dev/null +++ b/backend/migrations/2023-12-20-2117-agent-table.ts @@ -0,0 +1,16 @@ +import { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + // Create the user table + return knex.schema.createTable("agent", (table) => { + table.increments("id"); + table.string("url", 255).notNullable().unique(); + table.string("username", 255).notNullable(); + table.string("password", 255).notNullable(); + table.boolean("active").notNullable().defaultTo(true); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable("agent"); +} diff --git a/backend/models/agent.ts b/backend/models/agent.ts new file mode 100644 index 0000000..f806a3d --- /dev/null +++ b/backend/models/agent.ts @@ -0,0 +1,31 @@ +import { BeanModel } from "redbean-node/dist/bean-model"; +import { R } from "redbean-node"; +import { LooseObject } from "../../common/util-common"; + +export class Agent extends BeanModel { + + static async getAgentList() : Promise> { + let list = await R.findAll("agent") as Agent[]; + let result : Record = {}; + for (let agent of list) { + result[agent.endpoint] = agent; + } + return result; + } + + get endpoint() : string { + let obj = new URL(this.url); + return obj.host; + } + + toJSON() : LooseObject { + return { + url: this.url, + username: this.username, + endpoint: this.endpoint, + }; + } + +} + +export default Agent; diff --git a/backend/settings.ts b/backend/settings.ts index c1703dc..bbc00a0 100644 --- a/backend/settings.ts +++ b/backend/settings.ts @@ -1,6 +1,6 @@ import { R } from "redbean-node"; import { log } from "./log"; -import { LooseObject } from "./util-common"; +import { LooseObject } from "../common/util-common"; export class Settings { diff --git a/backend/socket-handlers/agent-proxy-socket-handler.ts b/backend/socket-handlers/agent-proxy-socket-handler.ts new file mode 100644 index 0000000..b4ce32e --- /dev/null +++ b/backend/socket-handlers/agent-proxy-socket-handler.ts @@ -0,0 +1,47 @@ +import { SocketHandler } from "../socket-handler.js"; +import { DockgeServer } from "../dockge-server"; +import { log } from "../log"; +import { checkLogin, DockgeSocket } from "../util-server"; +import { AgentSocket } from "../../common/agent-socket"; +import { ALL_ENDPOINTS } from "../../common/util-common"; + +export class AgentProxySocketHandler extends SocketHandler { + + create2(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) { + // Agent - proxying requests if needed + socket.on("agent", async (endpoint : unknown, eventName : unknown, ...args : unknown[]) => { + try { + checkLogin(socket); + + // Check Type + if (typeof(endpoint) !== "string") { + throw new Error("Endpoint must be a string: " + endpoint); + } + if (typeof(eventName) !== "string") { + throw new Error("Event name must be a string"); + } + + if (endpoint === ALL_ENDPOINTS) { // Send to all endpoints + log.debug("agent", "Sending to all endpoints: " + eventName); + socket.instanceManager.emitToAllEndpoints(eventName, ...args); + + } else if (!endpoint || endpoint === socket.endpoint) { // Direct connection or matching endpoint + log.debug("agent", "Matched endpoint: " + eventName); + agentSocket.call(eventName, ...args); + + } else { + log.debug("agent", "Proxying request to " + endpoint + " for " + eventName); + await socket.instanceManager.emitToEndpoint(endpoint, eventName, ...args); + } + } catch (e) { + if (e instanceof Error) { + log.warn("agent", e.message); + } + } + }); + } + + create(socket : DockgeSocket, server : DockgeServer) { + throw new Error("Method not implemented. Please use create2 instead."); + } +} diff --git a/backend/socket-handlers/main-socket-handler.ts b/backend/socket-handlers/main-socket-handler.ts index e6734d5..5d31878 100644 --- a/backend/socket-handlers/main-socket-handler.ts +++ b/backend/socket-handlers/main-socket-handler.ts @@ -1,3 +1,5 @@ +// @ts-ignore +import composerize from "composerize"; import { SocketHandler } from "../socket-handler.js"; import { DockgeServer } from "../dockge-server"; import { log } from "../log"; @@ -5,7 +7,14 @@ import { R } from "redbean-node"; import { loginRateLimiter, twoFaRateLimiter } from "../rate-limiter"; import { generatePasswordHash, needRehashPassword, shake256, SHAKE256_LENGTH, verifyPassword } from "../password-hash"; import { User } from "../models/user"; -import { checkLogin, DockgeSocket, doubleCheckPassword, JWTDecoded } from "../util-server"; +import { + callbackError, + checkLogin, + DockgeSocket, + doubleCheckPassword, + JWTDecoded, + ValidationError +} from "../util-server"; import { passwordStrength } from "check-password-strength"; import jwt from "jsonwebtoken"; import { Settings } from "../settings"; @@ -262,8 +271,6 @@ export class MainSocketHandler extends SocketHandler { await doubleCheckPassword(socket, currentPassword); } - console.log(data); - await Settings.setSettings("general", data); callback({ @@ -294,6 +301,25 @@ export class MainSocketHandler extends SocketHandler { } } }); + + // composerize + socket.on("composerize", async (dockerRunCommand : unknown, callback) => { + try { + checkLogin(socket); + + if (typeof(dockerRunCommand) !== "string") { + throw new ValidationError("dockerRunCommand must be a string"); + } + + const composeTemplate = composerize(dockerRunCommand); + callback({ + ok: true, + composeTemplate, + }); + } catch (e) { + callbackError(e, callback); + } + }); } async login(username : string, password : string) : Promise { diff --git a/backend/socket-handlers/manage-agent-socket-handler.ts b/backend/socket-handlers/manage-agent-socket-handler.ts new file mode 100644 index 0000000..6f8fef4 --- /dev/null +++ b/backend/socket-handlers/manage-agent-socket-handler.ts @@ -0,0 +1,70 @@ +import { SocketHandler } from "../socket-handler.js"; +import { DockgeServer } from "../dockge-server"; +import { log } from "../log"; +import { callbackError, callbackResult, checkLogin, DockgeSocket } from "../util-server"; +import { LooseObject } from "../../common/util-common"; + +export class ManageAgentSocketHandler extends SocketHandler { + + create(socket : DockgeSocket, server : DockgeServer) { + // addAgent + socket.on("addAgent", async (requestData : unknown, callback : unknown) => { + try { + log.debug("manage-agent-socket-handler", "addAgent"); + checkLogin(socket); + + if (typeof(requestData) !== "object") { + throw new Error("Data must be an object"); + } + + let data = requestData as LooseObject; + let manager = socket.instanceManager; + await manager.test(data.url, data.username, data.password); + await manager.add(data.url, data.username, data.password); + + // connect to the agent + manager.connect(data.url, data.username, data.password); + + // Refresh another sockets + // It is a bit difficult to control another browser sessions to connect/disconnect agents, so force them to refresh the page will be easier. + server.disconnectAllSocketClients(undefined, socket.id); + manager.sendAgentList(); + + callbackResult({ + ok: true, + msg: "agentAddedSuccessfully", + msgi18n: true, + }, callback); + + } catch (e) { + callbackError(e, callback); + } + }); + + // removeAgent + socket.on("removeAgent", async (url : unknown, callback : unknown) => { + try { + log.debug("manage-agent-socket-handler", "removeAgent"); + checkLogin(socket); + + if (typeof(url) !== "string") { + throw new Error("URL must be a string"); + } + + let manager = socket.instanceManager; + await manager.remove(url); + + server.disconnectAllSocketClients(undefined, socket.id); + manager.sendAgentList(); + + callbackResult({ + ok: true, + msg: "agentRemovedSuccessfully", + msgi18n: true, + }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + } +} diff --git a/backend/stack.ts b/backend/stack.ts index 1fbfa13..fbce500 100644 --- a/backend/stack.ts +++ b/backend/stack.ts @@ -15,9 +15,10 @@ import { PROGRESS_TERMINAL_ROWS, RUNNING, TERMINAL_ROWS, UNKNOWN -} from "./util-common"; +} from "../common/util-common"; import { InteractiveTerminal, Terminal } from "./terminal"; import childProcessAsync from "promisify-child-process"; +import { Settings } from "./settings"; export class Stack { @@ -50,22 +51,41 @@ export class Stack { } } - toJSON() : object { - let obj = this.toSimpleJSON(); + async toJSON(endpoint : string) : Promise { + + // Since we have multiple agents now, embed primary hostname in the stack object too. + let primaryHostname = await Settings.get("primaryHostname"); + if (!primaryHostname) { + if (!endpoint) { + primaryHostname = "localhost"; + } else { + // Use the endpoint as the primary hostname + try { + primaryHostname = (new URL("https://" + endpoint).hostname); + } catch (e) { + // Just in case if the endpoint is in a incorrect format + primaryHostname = "localhost"; + } + } + } + + let obj = this.toSimpleJSON(endpoint); return { ...obj, composeYAML: this.composeYAML, composeENV: this.composeENV, + primaryHostname, }; } - toSimpleJSON() : object { + toSimpleJSON(endpoint : string) : object { return { name: this.name, status: this._status, tags: [], isManagedByDockge: this.isManagedByDockge, composeFileName: this._composeFileName, + endpoint, }; } @@ -186,8 +206,8 @@ export class Stack { } } - async deploy(socket? : DockgeSocket) : Promise { - const terminalName = getComposeTerminalName(this.name); + async deploy(socket : DockgeSocket) : Promise { + const terminalName = getComposeTerminalName(socket.endpoint, this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); if (exitCode !== 0) { throw new Error("Failed to deploy, please check the terminal output for more information."); @@ -195,8 +215,8 @@ export class Stack { return exitCode; } - async delete(socket?: DockgeSocket) : Promise { - const terminalName = getComposeTerminalName(this.name); + async delete(socket: DockgeSocket) : Promise { + const terminalName = getComposeTerminalName(socket.endpoint, this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans" ], this.path); if (exitCode !== 0) { throw new Error("Failed to delete, please check the terminal output for more information."); @@ -388,7 +408,7 @@ export class Stack { } async start(socket: DockgeSocket) { - const terminalName = getComposeTerminalName(this.name); + const terminalName = getComposeTerminalName(socket.endpoint, this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); if (exitCode !== 0) { throw new Error("Failed to start, please check the terminal output for more information."); @@ -397,7 +417,7 @@ export class Stack { } async stop(socket: DockgeSocket) : Promise { - const terminalName = getComposeTerminalName(this.name); + const terminalName = getComposeTerminalName(socket.endpoint, this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "stop" ], this.path); if (exitCode !== 0) { throw new Error("Failed to stop, please check the terminal output for more information."); @@ -406,7 +426,7 @@ export class Stack { } async restart(socket: DockgeSocket) : Promise { - const terminalName = getComposeTerminalName(this.name); + const terminalName = getComposeTerminalName(socket.endpoint, this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "restart" ], this.path); if (exitCode !== 0) { throw new Error("Failed to restart, please check the terminal output for more information."); @@ -415,7 +435,7 @@ export class Stack { } async down(socket: DockgeSocket) : Promise { - const terminalName = getComposeTerminalName(this.name); + const terminalName = getComposeTerminalName(socket.endpoint, this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down" ], this.path); if (exitCode !== 0) { throw new Error("Failed to down, please check the terminal output for more information."); @@ -424,7 +444,7 @@ export class Stack { } async update(socket: DockgeSocket) { - const terminalName = getComposeTerminalName(this.name); + const terminalName = getComposeTerminalName(socket.endpoint, this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull" ], this.path); if (exitCode !== 0) { throw new Error("Failed to pull, please check the terminal output for more information."); @@ -445,7 +465,7 @@ export class Stack { } async joinCombinedTerminal(socket: DockgeSocket) { - const terminalName = getCombinedTerminalName(this.name); + const terminalName = getCombinedTerminalName(socket.endpoint, this.name); const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f", "--tail", "100" ], this.path); terminal.enableKeepAlive = true; terminal.rows = COMBINED_TERMINAL_ROWS; @@ -455,7 +475,7 @@ export class Stack { } async leaveCombinedTerminal(socket: DockgeSocket) { - const terminalName = getCombinedTerminalName(this.name); + const terminalName = getCombinedTerminalName(socket.endpoint, this.name); const terminal = Terminal.getTerminal(terminalName); if (terminal) { terminal.leave(socket); @@ -463,7 +483,7 @@ export class Stack { } async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = "sh", index: number = 0) { - const terminalName = getContainerExecTerminalName(this.name, serviceName, index); + const terminalName = getContainerExecTerminalName(socket.endpoint, this.name, serviceName, index); let terminal = Terminal.getTerminal(terminalName); if (!terminal) { diff --git a/backend/terminal.ts b/backend/terminal.ts index dedc4e0..4a5d6b2 100644 --- a/backend/terminal.ts +++ b/backend/terminal.ts @@ -8,7 +8,7 @@ import { PROGRESS_TERMINAL_ROWS, TERMINAL_COLS, TERMINAL_ROWS -} from "./util-common"; +} from "../common/util-common"; import { sync as commandExistsSync } from "command-exists"; import { log } from "./log"; @@ -34,6 +34,9 @@ export class Terminal { public enableKeepAlive : boolean = false; protected keepAliveInterval? : NodeJS.Timeout; + protected kickDisconnectedClientsInterval? : NodeJS.Timeout; + + protected socketList : Record = {}; constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) { this.server = server; @@ -82,13 +85,22 @@ export class Terminal { return; } + this.kickDisconnectedClientsInterval = setInterval(() => { + for (const socketID in this.socketList) { + const socket = this.socketList[socketID]; + if (!socket.connected) { + log.debug("Terminal", "Kicking disconnected client " + socket.id + " from terminal " + this.name); + this.leave(socket); + } + } + }, 60 * 1000); + if (this.enableKeepAlive) { log.debug("Terminal", "Keep alive enabled for terminal " + this.name); // Close if there is no clients this.keepAliveInterval = setInterval(() => { - const clients = this.server.io.sockets.adapter.rooms.get(this.name); - const numClients = clients ? clients.size : 0; + const numClients = Object.keys(this.socketList).length; if (numClients === 0) { log.debug("Terminal", "Terminal " + this.name + " has no client, closing..."); @@ -112,8 +124,10 @@ export class Terminal { // On Data this._ptyProcess.onData((data) => { this.buffer.pushItem(data); - if (this.server.io) { - this.server.io.to(this.name).emit("terminalWrite", this.name, data); + + for (const socketID in this.socketList) { + const socket = this.socketList[socketID]; + socket.emitAgent("terminalWrite", this.name, data); } }); @@ -137,15 +151,19 @@ export class Terminal { * @param res */ protected exit = (res : {exitCode: number, signal?: number | undefined}) => { - this.server.io.to(this.name).emit("terminalExit", this.name, res.exitCode); + for (const socketID in this.socketList) { + const socket = this.socketList[socketID]; + socket.emitAgent("terminalExit", this.name, res.exitCode); + } - // Remove room - this.server.io.in(this.name).socketsLeave(this.name); + // Remove all clients + this.socketList = {}; Terminal.terminalMap.delete(this.name); log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode); clearInterval(this.keepAliveInterval); + clearInterval(this.kickDisconnectedClientsInterval); if (this.callback) { this.callback(res.exitCode); @@ -157,11 +175,11 @@ export class Terminal { } public join(socket : DockgeSocket) { - socket.join(this.name); + this.socketList[socket.id] = socket; } public leave(socket : DockgeSocket) { - socket.leave(this.name); + delete this.socketList[socket.id]; } public get ptyProcess() { diff --git a/backend/util-server.ts b/backend/util-server.ts index 0277346..227ece0 100644 --- a/backend/util-server.ts +++ b/backend/util-server.ts @@ -2,10 +2,11 @@ import { Socket } from "socket.io"; import { Terminal } from "./terminal"; import { randomBytes } from "crypto"; import { log } from "./log"; -import { ERROR_TYPE_VALIDATION } from "./util-common"; +import { ERROR_TYPE_VALIDATION } from "../common/util-common"; import { R } from "redbean-node"; import { verifyPassword } from "./password-hash"; import fs from "fs"; +import { AgentManager } from "./agent-manager"; export interface JWTDecoded { username : string; @@ -15,6 +16,9 @@ export interface JWTDecoded { export interface DockgeSocket extends Socket { userID: number; consoleTerminal? : Terminal; + instanceManager : AgentManager; + endpoint : string; + emitAgent : (eventName : string, ...args : unknown[]) => void; } // For command line arguments, so they are nullable @@ -56,18 +60,28 @@ export function callbackError(error : unknown, callback : unknown) { callback({ ok: false, msg: error.message, + msgi18n: true, }); } else if (error instanceof ValidationError) { callback({ ok: false, type: ERROR_TYPE_VALIDATION, msg: error.message, + msgi18n: true, }); } else { log.debug("console", "Unknown error: " + error); } } +export function callbackResult(result : unknown, callback : unknown) { + if (typeof(callback) !== "function") { + log.error("console", "Callback is not a function"); + return; + } + callback(result); +} + export async function doubleCheckPassword(socket : DockgeSocket, currentPassword : unknown) { if (typeof currentPassword !== "string") { throw new Error("Wrong data type?"); diff --git a/common/agent-socket.ts b/common/agent-socket.ts new file mode 100644 index 0000000..6748e44 --- /dev/null +++ b/common/agent-socket.ts @@ -0,0 +1,15 @@ +export class AgentSocket { + + eventList : Map void> = new Map(); + + on(event : string, callback : (...args : unknown[]) => void) { + this.eventList.set(event, callback); + } + + call(eventName : string, ...args : unknown[]) { + const callback = this.eventList.get(eventName); + if (callback) { + callback(...args); + } + } +} diff --git a/backend/util-common.ts b/common/util-common.ts similarity index 93% rename from backend/util-common.ts rename to common/util-common.ts index 433994c..587e6dd 100644 --- a/backend/util-common.ts +++ b/common/util-common.ts @@ -43,6 +43,8 @@ async function initRandomBytes() { } } +export const ALL_ENDPOINTS = "##ALL_DOCKGE_ENDPOINTS##"; + // Stack Status export const UNKNOWN = 0; export const CREATED_FILE = 1; @@ -206,20 +208,20 @@ export function getCryptoRandomInt(min: number, max: number):number { } } -export function getComposeTerminalName(stack : string) { - return "compose-" + stack; +export function getComposeTerminalName(endpoint : string, stack : string) { + return "compose-" + endpoint + "-" + stack; } -export function getCombinedTerminalName(stack : string) { - return "combined-" + stack; +export function getCombinedTerminalName(endpoint : string, stack : string) { + return "combined-" + endpoint + "-" + stack; } -export function getContainerTerminalName(container : string) { - return "container-" + container; +export function getContainerTerminalName(endpoint : string, container : string) { + return "container-" + endpoint + "-" + container; } -export function getContainerExecTerminalName(stackName : string, container : string, index : number) { - return "container-exec-" + stackName + "-" + container + "-" + index; +export function getContainerExecTerminalName(endpoint : string, stackName : string, container : string, index : number) { + return "container-exec-" + endpoint + "-" + stackName + "-" + container + "-" + index; } export function copyYAMLComments(doc : Document, src : Document) { @@ -289,10 +291,9 @@ function copyYAMLCommentsItems(items : any, srcItems : any) { * - "127.0.0.1:5000-5010:5000-5010" * - "6060:6060/udp" * @param input - * @param defaultHostname + * @param hostname */ -export function parseDockerPort(input : string, defaultHostname : string = "localhost") { - let hostname = defaultHostname; +export function parseDockerPort(input : string, hostname : string) { let port; let display; @@ -405,3 +406,4 @@ function traverseYAML(pair : Pair, env : DotenvParseOutput) : void { pair.value.value = envsubst(pair.value.value, env); } } + diff --git a/extra/reset-password.ts b/extra/reset-password.ts index 4ba51ee..696fb4c 100644 --- a/extra/reset-password.ts +++ b/extra/reset-password.ts @@ -5,7 +5,7 @@ import { User } from "../backend/models/user"; import { DockgeServer } from "../backend/dockge-server"; import { log } from "../backend/log"; import { io } from "socket.io-client"; -import { BaseRes } from "../backend/util-common"; +import { BaseRes } from "../common/util-common"; console.log("== Dockge Reset Password Tool =="); @@ -92,7 +92,6 @@ function disconnectAllSocketClients(username : string, password : string) : Prom // Disconnect all socket connections const socket = io(url, { - transports: [ "websocket" ], reconnection: false, timeout: 5000, }); diff --git a/frontend/src/components/Container.vue b/frontend/src/components/Container.vue index 0a20182..8741d14 100644 --- a/frontend/src/components/Container.vue +++ b/frontend/src/components/Container.vue @@ -137,7 +137,7 @@ diff --git a/frontend/src/pages/Console.vue b/frontend/src/pages/Console.vue index 18c541b..7b566fc 100644 --- a/frontend/src/pages/Console.vue +++ b/frontend/src/pages/Console.vue @@ -15,14 +15,14 @@

- +