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
-
+